乐趣区

读书笔记之Java并发编程的艺术-一

前言

    这本书集合 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 操作。

退出移动版