前言
这本书集合JDK的源码讲了Java并发框架、线程池的原理等,深入到JVM、CPU层面来讲解。推荐看过《Java多线程编程核心技术》 之后,可以继续研究此书,提高自己。全书分为11章,下面将记录个人认为每章中重要的知识点。
一、并发编程的挑战
并发编程的目的是为了让程序执行的更快,并不是启动更多的线程就能让程序最大限度的并发执行。需要考虑很多因素,比如 上下文切换、死锁,以及硬件和软件资源的限制。
1、上下文切换
CPU通过时间片分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,切换前会保存上一个任务的状态。任务从保存到再加载的过程 就是一次上下文切换。上下文切换会影响多线程的执行速度。
(1)如何减少上下文切换?
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程:
无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些方法来避免使用锁。
CAS算法:Atomic包使用CAS算法来更新数据,不需要加锁。
使用最少线程:避免创建不需要的线程,比如任务很少,如果创建了很多线程,那么会造成大量线程处于等待状态。
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
注意:可以通过减少WAITING的线程,来减少上下文切换次数。因为每一次WAITING到RUNNABLE都会进行一次上下文的切换。
2、死锁
避免死锁的几个方法:
避免一个线程同时获取多个锁;
避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源;
尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制;
对于数据库锁,加锁和解锁必须在一个数据库里,否则会出现解锁失败的情况。
3、资源限制的挑战
(1)资源限制的含义
资源限制是指进行并发编程时,程序的执行速度受限于硬件资源和软件资源。硬件资源的限制有带宽的上传、下载速度,磁盘的读写速度和CPU的处理 速度;软件资源限制有数据库的连接和socket连接数等。
(2)资源限制引发的问题
代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,如果因为资源受限的原因,将某段串行的代码改为并发执行,效率反而会慢下来, 因为上下文切换和资源调度都需要时间。
(3)如何解决资源限制的问题
硬件资源的限制可以通过集群的方式解决;软件资源的限制可以考虑使用资源池将资源复用。
(4)在资源限制情况下进行并发编程
根据不同的资源限制调整程序的并发度。
二、Java并发机制的底层实现原理
Java中所使用的并发机制依赖于JVM的实现和CPU的指令。
1、volatile
volatile是轻量级的synchronized,在多处理器的开发中保证了共享变量的可见性(当一个线程修改一个共享变量时,另外一个线程能够读取到这个修改的值)。
有volatile变量修饰的共享变量进行写操作的时候,CPU会加上lock前缀的汇编指令。Lock前缀的指令在多核处理器下会引发:
将当前处理器缓存行的数据写回到系统内存;
这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。
在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的数据是不是过期了。
volatile的两条实现原则:
Lock前缀指令会引起处理器缓存回写到内存
一个处理器的缓存回写到内存会导致其他处理器的缓存无效
2、synchronized
(1)利用synchronized实现同步的基础,Java中的每一个对象都可以作为锁:
对于普通方法,锁是当前实例对象;
对于静态同步方法,锁是当前类的Class对象;
对于同步方法块,锁是Synchronized括号里配置的对象。
Synchronized在JVM里的实现原理:JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,用monitorenter和monitorexit指令实现。 monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit是插入到方法的结束处和异常处。JVM要保证每个monitorenter有对应的 monitorexit配对。任何对象都有一个monitor与之关联,并且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试 获取对象所对应的monitor的所有权,即尝试获得对象的锁。
(2)synchronized用的锁是存在Java对象头里的,对象头里的Mark Word默认存储对象的HashCode、分代年龄和锁标记位。注意32位虚拟机和64位虚拟 机下Mark Word的存储结构不一样。
(3)锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。锁可以升级但是 不能降级。
偏向锁:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取 锁时,会在对象头存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要测试下对象头的Mark Word里是否存储着 指向当前线程的偏向锁。测试成功那么线程已经获得了锁,测试失败,会在测试下Mark Word中偏向锁的标识是否置为1(表示当前是偏向锁),如果设置了,尝试 使用CAS将对象头的偏向锁指向当前线程,没有设置,就使用CAS竞争锁。
偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
注意:JVM参数可以控制关闭激活偏向锁的延迟和关闭偏向锁。
轻量级锁:CAS修改Mark Word,成功的话,当前线程获得锁,失败表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。 轻量级锁释放:CAS替换Mark Word,成功的话,没竞争。失败的话,表示存在竞争,锁会膨胀为重量级锁。
(4)锁的优缺点对比
偏向锁:
优点:加锁和解锁不需要额外的消耗;
缺点:如果线程间存在锁竞争,会带来额外的锁撤销的消耗;
适用场景:适用于只有一个线程访问同步块场景。
轻量级锁:
优点:竞争的线程不会阻塞,提高了程序的响应速度;
缺点:如果始终得不到锁竞争的线程,使用自旋会消耗CPU;
适用场景:追求响应时间,同步块执行速度非常快。
重量级锁:
优点:线程竞争不使用自旋,不会消耗CPU;
缺点:线程阻塞,响应时间缓慢;
适用场景:追求吞吐量,同步块执行速度较长。
3、原子操作的实现原理
原子操作指的是不可中断的一个或一系列操作。CAS操作需要输入两个数值,一个旧值一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才变换 成新值,发生了变化则不交换。
处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。总线锁是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其 他处理器的请求将被阻塞住。总线锁定把CPU和内存之间的通信锁住了,这样锁定期间其他处理器不能操作其他内存地址的数据,处理器在某些场合下会使用缓存锁定 代替总线锁定来进行优化。
4、Java如何实现原子操作
Java中可以通过锁和循环CAS的方式来实现原子操作。CAS存在三大问题:ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作。
(1)ABA问题:因为CAS需要在操作值的时候,检查值有没有变化,如果没有变化就更新。但是如果原来的值A,变成了B,又变成了A。那么使用CAS检查时会发现 它的值没有变化,实际上是变化了的。ABA问题的解决思路是使用版本号,在变量前追加版本号,每次变量更新的时候把版本号加1。
(2)循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
(3)只能保证一个共享变量的原子操作
对多个共享变量操作时,循环CAS无法保证操作的原子性。Java1.5开始,JDK提供了AtomicReference类保证引用对象之间的原子性,可以把多个变量放在一个 对象里进行CAS操作。