并发编程的艺术() ------知识点梳理与总结

第一章  并发编程的挑战(2021-10-19)

上下文切换

上下文切换就是由于cpu根据时间片轮转的机制要分配给下一个任务执行,但是这一个任务还没有执行完毕,就需要保存这个任务的状态,以便之后能够重新执行该任务。这样的任务从保存到加载的过程就是一次上下文切换。 

 

什么情况下会进行上下文轮转算法:

当多个任务抢占锁资源,当前任务没有抢到,被调度器挂起,继续下一个任务。

当前任务碰到IO阻塞时,调度线程将其挂起,继续下一个任务。

所以在多个任务抢占或IO阻塞时,CPU可能把线程切换上来,但是没有抢到锁因此什么都干不了。继续切回去。线程越多的话你抢占的概率就越小。

 

如何减少上下文切换

1、无锁并发编程:

在多线程争夺锁的时候会引起很多的上下文切换,因为在多线程处理数据的时候,要避免使用锁。例如将数据的ID通过Hash算法取模分段,不同线程处理不同段的数据。

2、CAS算法

java的atomic包使用CAS算法来更新数据而不用加锁

3、使用最少线程

避免创建不需要的线程,比如任务很少,但是创建了很多线程,这样就会造成大量线程处于等待状态。

4、协程

在单线程里是实现多任务的调度,并在单线程里维持多个任务间的切换。

 

减少上下文切换实战:

 通过减少线程池中的waiting线程的数目来达到减少上下文切换。

 

死锁

是线程t1和线程t2互相等待对方释放锁。通常会遇到这样的情况:t1拿到锁后,因为一些异常情况没有释放锁(死循环)。又或者是t1拿到一个数据库锁,释放锁的时候抛出了异常,每释放掉。

 

避免死锁的几个常见方法:

避免一个线程同时获得多个锁

避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。

尝试使用定时锁,使用lock.tryLock(timeout) 来代替使用内部锁机制

对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

 

资源限制的挑战

什么是资源限制:

在进行并发编程时,程序的执行速度受限于计算机硬件资源或者软件资源。硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU处理速度。软件资源限制有数据库的连接数和socket的连接数。

资源限制引发的问题:

将程序并发运行本应该是提高程序运行的速度,但是受限于资源,使又是保持串行的运行,但是增加了其他的上下文切换所需要的时间以及资源调度的时间。

如何解决资源限制的问题:

对于硬件资源限制,可以考虑使用集群并性执行程序。因为单机的资源有限,就使用很多台机器搭建集群进行处理。

对于软件资源限制,可以考虑使用资源池将资源复用。

在资源限制情况下进行并发编程:

根据不同的资源限制调整程序的并发度。

 

小结:

建议使用JDK并发包提供的并发容器和工具类来解决并发问题。

 

 

第二章  Java并发机制的底层实现原理

JAVA中所使用的并发机制依赖于JVM的实现和cpu的指令

volatile的应用

volatile是轻量级的synchronized,在多处理器开发中保证了共享变量的可见性。可见性是什么呢?就是在一个线程修改共享变量时,另一个线程能够读到这个修改的值。如果volatile使用恰当的话,使用的成本比synchronized更低,因

为它不会引起上下文的切换和调度。

 

volatile的定义与实现原理

java语言规范第三版中的定义如下:java编程语言允许线程访问共享变量,为了确保共享变量能够被准确和一致性地更新,线程应该确保通过排他锁单独获得这个变量。

实现原理相关术语说明:

内存屏障 :(memory barriers)是一组处理器指令,用于实现对内存操作的顺序限制。

缓冲行:(cache line):cpu高速缓存中可以分配的最小存储单元。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令

原子操作(atomic operations)不可中断的一个或一系列操作

缓存行填充(cache line fill)当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或所有)

缓存命中(cache hit)如果进行高速缓存行填充操作的内存位置依然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取。

写命中(wirte hit)当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中

写缺失(write misses the cache)一个有效的缓存行被写入到不存在的内存区域。

 

volatile是如何保持可见性的呢?通过汇编代码可知,在有volatile修饰的共享变量进行读写操作的时候会多出一行代码。带有Lock前缀的代码。带有Lock 前缀的指令在多核处理器中会引发两件事情:

1)将当前处理器缓存行的数据写回到系统内存

2)这个写回内存的操作会使在其他的cpu里缓存了改内存地址的数据无效

为了提交处理的速度,处理不直接和内存进行通信,而是将系统内存的数据读到内部缓存(L1,L2或者其他)后再进行操作,但是有一个问题,就是我在操作完数据后不知道何时将数据写回内存。但如果用volatile修饰的变量去进行写操作,JVM就会发送一条Lock前缀的指令给处理器,将这个变量所在缓存行的数据写回到系统内存。但是就算写回到内存,因为其他处理器的缓存的数据还可能时旧的,所以就会出现计算的问题。因此在多处理器下,为了保证各缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作时,就会从内存中将数据读到处理器缓存中。

 

volatile的使用优化

采用追加字节的方式可以优化队列的性能。因为处理器的架构高速缓存行是64个字节宽。如果队列的头节点和尾节点都不足64个字节的话,处理器会将他们读到同一个缓存行中,当需要修改头节点或者尾节点的值的时候,就会将整个的缓存行锁定,这样的话就会导致其他处理器不能访问自己高速缓存中的尾节点。追加字节可以避免头节点和尾节点被同时锁定。

那什么时候不使用比较合理呢?

1、缓存行非64字节宽的处理器

2、共享变量不会被频繁地读写   (追加字节本来就会造成一定的消耗)

 

synchronized的实现原理和使用

重量级锁 

每一个对象都可以作为锁。具体有三种形式:

1、对于普通同步方法,锁就是当前实例对象

2、对于静态同步方法,锁就是当前类的class对象

3、对于同步方法块,锁就是synchonized括号里配置的对象

 

synchronized在JVM里面的实现原理:

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但是实现细节不一样。实现代码块同步是通过monitorenter和monitorexit指令实现的,方法同步使用另外一种方法实现的,在JVM规范中没有说明,但是也可以使用这种

方法来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM保证每一个monitorenter要和monitorexit与之配对。任何一个对象都要和monitor与之关联,当且一个monitor被持有后,它

将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

 

Java对象头

synchronized用的锁是存储在Java对象头里的。如果对象是数组类型,则虚拟机使用3个字宽存储对象头,如果是非数组,则采用两个字宽存储对象头。

32位的对象头的长度

Mark Word        存储对象的hashcode或锁信息等

Class Metadata Address  存储到对象类型数据的指针

Array length         数组的长度(如果当前的对象是数组)

 

锁的升级与对比

首先?为什么要升级锁,是为了减少获得锁和释放锁带来的性能消耗。引入了偏向锁和轻量级锁。 javaSE1.6中,锁一共有四个状态,级别由高到低分别是无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。这几个状态

会随着竞争情况而逐渐升级,锁可以升级但是不能降级。这种策略是为了提高获得锁和释放锁的效率。

 

1、偏向锁

在大多数情况下,锁不仅存在多线程竞争,而且存在总是由同一线程多次获得,为了让线程获取锁的代价更低所以引入了偏向锁。当一个线程访问同步块并且获取锁时,会在对象头和栈帧的锁记录中存储锁偏向的线程ID,以后该线程在进入和退出同步块的时不需要进行CAS操作来进行加锁和解锁。只是简单测试一下对象头中是否存储着指向该线程的偏向锁。如果测试成功,就表示线程已经获得了锁;如果测试失败,则还要测试Mark Word中偏向锁的标识是否为1 (表示当前是偏向锁),如果没有设置,则进行CAS竞争锁,如果有设置,则尝试使用CAS将对象头的偏向锁指向当前线程。

(1)偏向锁的撤销

 偏向锁使用了一种等到竞争出现才释放锁的机制。所以当其他线程要尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,要等待全局安全点(在这个时间点上没有正在执行的字节码).它会首先暂停拥有偏向锁的线

程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢

复到无锁活着标记对象不适合作为偏向锁,最后唤醒暂停的线程.

 

2、轻量级锁

轻量级锁加锁

线程在执行同步块之前会在栈帧中创建一个用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试将对象头中的Mark Word替换成指向锁记录的指针。如果成功则获得锁,如果失败,表示其他线程在竞争锁,当前线程便尝试使用自旋来获取锁

轻量级锁解锁

会使用原子的CAS操作将Displaced Mark Word重新写到对象头里面,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

 

 锁的优缺点对比:

偏向锁:加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距     缺点:如果线程之间存在锁的竞争,则会带来额外的锁撤销的消耗

轻量级锁:竞争的线程不会阻塞,提高了程序的响应程度   缺点:如果始终得不到竞争的线程,使用自旋会消耗CPU

重量级锁:线程竞争不使用自旋,不会消耗cpu    缺点:线程阻塞,响应时间缓慢

 

 

 

 

 

 

 

 

 、、

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

posted @ 2021-10-19 20:05  夏天·烟火·我的尸体  阅读(83)  评论(0)    收藏  举报