Threads and Locks
用户创建线程的唯一方式是创建 Thread 类的一个实例,每一个线程都和这样的一个实例关联。
这个章节将描述多线程编程的语义问题,包括一系列的规则,这些规则定义了在多线程环境中线程对共享内存中值的修改是否对其他线程立即可见。
同步(synchronization)
最基本的多种线程之间通信的机制。
java中的每个对象都关联了一个监视器,线程可以对其进行加锁和解锁操作。在同一时间,只有一个线程可以拿到对象上的监视器锁。如果其他线程在锁被占用期间试图去获取锁,那么将会被阻塞直到成功获取到锁。
同步机制:synchronized 关键字、对 volatile 变量的读写、使用 java.util.concurrent 包中的同步工具类等
等待集合 和 唤醒(Wait Sets and Notification)
每个 java 对象,都关联了一个监视器,也关联了一个等待集合。等待集合是一个线程集合。
等待 (Wait)
我们在线程 t 中对对象 m 调用 m.wait() 方法,n 代表加锁编号,同时还没有相匹配的解锁操作,则下面的其中之一会发生:
- 如果 n 等于 0(如线程 t 没有持有对象 m 的锁),那么会抛出 IllegalMonitorStateException 异常。
- 如果线程 t 调用的是 m.wait(millisecs) 或m.wait(millisecs, nanosecs),形参要正确,否则会抛出 IllegalArgumentException 异常。
- 如果线程 t 被中断,此时中断状态为 true,则 wait 方法将抛出 InterruptedException 异常,并将中断状态设置为 false。
- 否则,下面的操作会顺序发生:
- 线程 t 会加入到对象 m 的等待集合中,执行 加锁编号 n 对应的解锁操作
- 线程 t 不会执行任何进一步的指令。在发生以下操作的时候,线程 t 会从 m 的等待集合中移出,获取锁后,并继续执行之后的指令
- 在 m上执行了 notify 操作,而且线程 t 被选中从等待集合中移除。
- 在 m 上执行了 notifyAll 操作,那么线程 t 会从等待集合中移除。
- 线程 t 发生了 interrupt 操作。
- 如果线程 t 是调用 wait 的重载方法,那么过了millisecs 毫秒后线程 t 也会从等待集合中移出。
- JVM 的“假唤醒”,虽然这是不鼓励的,但是这种操作是被允许的,这样 JVM 能实现将线程从等待集合中移出,而不必等待具体的移出指令。
- 线程 t 执行编号为 n 的加锁操作(同上面说的)
通知(Notification)
我们在线程 t 中对对象 m 调用 m.notify() 或 m.notifyAll() 方法,n 代表加锁编号,同时对应的解锁操作没有执行,则下面的其中之一会发生:
- 如果 n 等于 0,抛出 IllegalMonitorStateException 异常,因为线程 t 还没有获取到对象 m 上的锁。
- notify 操作,如果 m 的等待集合不为空,那么等待集合中的线程 u 被选中从等待集合中移出。
- notifyAll 操作,那么等待集合中的所有线程都将从等待集合中移出,然后恢复。
中断(Interruptions)
令线程 t 调用线程 u 上的方法 u.interrupt(),其中 t 和 u 可以是同一个线程,这个操作会将 u 的中断状态设置为 true。
wait、如果线程阻塞在 InterruptibleChannel 类的 IO 操作中、如果线程阻塞在一个 Selector 中,
如果线程阻塞在以上3种情况中,那么当线程感知到中断状态后(此线程的 interrupt() 方法被调用),会将中断状态重新设置为 false,然后执行相应的操作(通常就是跳到 catch 异常处)。
等待、通知和中断 的交互(Interactions of Waits, Notification, and Interruption)
如果一个线程在等待期间,同时发生了通知和中断,它将发生:
从 wait 方法中正常返回,同时不重置中断状态(也就是说,调用 Thread.interrupted 方法将会返回 true)
由于抛出了 InterruptedException 异常而从 wait 方法中返回,中断状态设置为 false
休眠和礼让(Sleep and Yield)
休眠期间,线程不会释放任何的监视器锁。
Thread.sleep 和 Thread.yield 都不具有同步的语义。
内存模型
并发三问题
重排序、内存可见性以及原子性,这些也是并发程序为什么难写的原因。
- 重排序由以下几种机制引起:
- 编译器优化:对于没有数据依赖关系的操作,编译器在编译的过程中会进行一定程度的重排。
- 指令重排序:CPU 优化行为,也是会对不存在数据依赖关系的指令进行一定程度的重排。
- 内存系统重排序:内存系统没有重排序,但是由于有缓存的存在,使得程序整体上会表现出乱序的行为。
- 内存可见性
Java 作为高级语言,屏蔽了CPU中每个核心的一级缓存和二级缓存的问题,但是,JMM 抽象了主内存和本地内存的概念。所有的共享变量存在于主内存中,每个线程有自己的本地内存,线程读写共享数据也是通过本地内存交换的,所以可见性问题依然是存在的。 - 原子性
Java 编程语言规范中提到,对于 64 位的值的写入,可以分为两个 32 位的操作进行写入。
这个时候我们要使用 volatile 关键字进行控制了,JMM 规定了对于 volatile long 和 volatile double,JVM 需要保证写入操作的原子性。
Java 对于并发的规范约束
Java 编程语言规范,使我们写出来的并发代码能准确预测执行结果。
Synchronization Order
- 对于监视器 m 的解锁与所有后续操作对于 m 的加锁同步
- 对 volatile 变量 v 的写入,与所有其他线程后续对 v 的读同步
- 启动线程的操作与线程中的第一个操作同步。
- 对于每个属性写入默认值(0, false,null)与每个线程对其进行的操作同步。
- 线程 T1 的最后操作与线程 T2 发现线程 T1 已经结束同步。即线程 T2 可以通过 T1.isAlive() 或 T1.join() 方法来判断 T1 是否已经终结。
- 如果线程 T1 中断了 T2,那么线程 T1 的中断操作与其他所有线程发现 T2 被中断了同步
Happens-before Order
如果一个操作 happens-before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的。
- 如果操作 x 和操作 y 是同一个线程的两个操作,并且在代码上操作 x 先于操作 y 出现,那么有 hb(x, y)
- 对象构造方法的最后一行指令 happens-before 于 finalize() 方法的第一行指令。
- 如果操作 x 与随后的操作 y 构成同步,那么 hb(x, y)。这条说的是前面一小节的内容。
- hb(x, y) 和 hb(y, z),那么可以推断出 hb(x, z)
这里再提一点,x happens-before y,并不是说 x 操作一定要在 y 操作之前被执行,而是说 x 的执行结果对于 y 是可见的,只要满足可见性,发生了重排序也是可以的。
synchronized 关键字
一旦进入 synchronized 代码块,首先,该线程对于共享变量的缓存就会失效,因此 synchronized 代码块中对于共享变量的读取需要从主内存中重新获取,也就能获取到最新的值。
退出代码块的时候的,会将该线程写缓冲区中的数据刷到主内存中,所以在 synchronized 代码块之前或 synchronized 代码块中对于共享变量的操作随着该线程退出 synchronized 块,会立即对其他线程可见。
单例模式中的双重检查
instance = new Singleton() 这句代码首先会申请一段空间,然后将各个属性初始化为零值(0/null),执行构造方法中的属性赋值[1],将这个对象的引用赋值给 instance[2]。在这个过程中,[1] 和 [2] 可能会发生重排序。
所以线程 b 拿到的 instance 是不完整的,解决方案是使用 volatile 关键字。
volatile 关键字
作用:内存可见性和禁止指令重排序。
- volatile 的内存可见性
读一个 volatile 变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个 volatile 属性会立即刷入到主内存。所以,volatile 读和 monitorenter 有相同的语义,volatile 写和 monitorexit 有相同的语义。 - volatile 的禁止重排序
volatile 的禁止重排序并不局限于两个 volatile 的属性操作不能重排序,而且是 volatile 属性操作和它周围的普通属性的操作也不能重排序。
根据 volatile 的内存可见性和禁止重排序,那么我们不难得出一个推论:线程 a 如果写入一个 volatile 变量,此时线程 b 再读取这个变量,那么此时对于线程 a 可见的所有属性对于线程 b 都是可见的。hb(x, z) 推导 - volatile 小结
- volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中有一个线程修改了此属性,其他线程可以立即得到修改后的值。在并发包的源码中,它使用得非常多。
- volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
- volatile 只能作用于属性,我们用 volatile 修饰属性,这样 compilers 就不会对这个属性做指令重排序。
- volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 所有其他线程后续对 v 的读操作。
- volatile 可以使得 long 和 double 的赋值是原子的,前面在说原子性的时候提到过。
final 关键字
在对象的构造方法中设置 final 属性,同时在对象初始化完成前,不要将此对象的引用写入到其他线程可以访问到的地方(不要让引用在构造函数中逸出)。如果这个条件满足,当其他线程看到这个对象的时候,那个线程始终可以看到正确初始化后的对象的 final 属性。
锁优化
这里的锁优化主要是指 JVM 对 synchronized 的优化。
自旋锁
自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
锁消除
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
1 | public static String concatString(String s1, String s2, String s3) { |
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
轻量级锁
JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
meituan-lock
原文链接:不可不说的Java“锁”事
自旋锁 VS 适应性自旋锁
自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS。自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(自旋的次数不再固定)。
无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
目前 synchronized 一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
- 无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。 - 偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。 - 轻量级锁
是指当锁是偏向锁(无锁不行??)的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。 - 重量级锁
锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
可重入锁 VS 非可重入锁
非可重入锁NonReentrantLock
独享锁 VS 共享锁
ReentrantReadWriteLock有两把锁:ReadLock和WriteLock。读锁是共享锁,写锁是独享锁。