# 线程并发关键字详解
# synchronized
# 使用
- 一把锁只能同时被一个线程获取,没有获取锁的线程只能等待。
- 每个实例都对应有自己的一把锁 (this),不同实例之间互不影响。例外:锁对象时 class 以及 synchronized 修饰的是 static 方法的时候,所有对象巩永固同一把锁。
- synchronized 修饰的方法,无论方法正常执行还是抛出异常,都会释放锁。
# 对象锁
包括方法锁 (默认锁对象为 this, 当前实例对象) 和同步代码块锁 (自己指定锁对象)
- 代码块形式:手动指定锁定对象,也可是是 this, 也可以是自定义的锁
- 方法锁形式:synchronized 修饰普通方法,锁对象默认为 this
# 类锁
指定 synchronized 修饰静态方法,或者指定锁对象为 Class 对象。
- synchronize 修饰静态方法
- synchronized 指定锁对象为 Class 对象
# 原理分析
# 加锁和释放锁的原理
Monitorenter
和 Monitorexit
指令,会让对象在执行,使其锁计数器加 1 或者减 1。每一个对象在同一时间只与一个 monitor (锁) 相关联,而一个 monitor 在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的 Monitor 锁的所有权的时候,monitorenter 指令会发生如下 3 中情况之一:
- monitor 计数器为 0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器 + 1,一旦 + 1,别的线程再想获取,就需要等待
- 如果这个 monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加
- 这把锁已经被别的线程获取了,等待锁释放
monitorexit指令
:释放对于 monitor 的所有权,释放过程很简单,就是讲 monitor 的计数器减 1,如果减完以后,计数器不是 0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成 0,则代表当前线程不再拥有该 monitor 的所有权,即释放锁。
下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
该图可以看出,任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败,该线程就进入同步状态,线程状态变为 BLOCKED,当 Object 的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。
# 可重入原理:加锁次数计数器
- 可重入:当一个子程序正在运行是,执行线程可以再次进入并执行它,仍然获得符合设计预期的结果,则称其为可重入(reentrant 或 re-entrant)。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时,重新进入同一个子程序仍然时安全的。
- 可重入锁:又名递归锁,是指同一个线程在外层方法获取锁的时候,再进入线程的内层方法会自动获取锁(前提是锁对象时同一个对象或者同一个 class),不会因为之前已经获取过还没有释放锁而阻塞。
这就是 Synchronized 的重入性,即在同一锁程中,每个对象拥有一个 monitor 计数器,当线程获取该对象锁后,monitor 计数器就会加一,释放锁后就会将 monitor 计数器减一,线程不需要再次获取同一把锁。
# 保证可见性原理:内存模型和 happens-before 规则
synchronized 的 happens-before 规则,即监视器锁规则:对同一个监视器的解锁,happens-before 对于该监视器的加锁。
即,A 线程拿到锁修改了共享变量后,在释放锁之前,会将缓存中修改后的值刷入主存当中,通知其他线程共享变量已被修改。然后 B 线程获取了锁以后,会自动去主存中获取更新后的共享变量值。
在图中每一个箭头连接的两个节点就代表之间的 happens-before 关系,黑色的是通过程序顺序规则推导出来,红色的为监视器锁规则推导而出:线程 A 释放锁 happens-before 线程 B 加锁,蓝色的则是通过程序顺序规则和监视器锁规则推测出来 happens-before 关系,通过传递性规则进一步推导的 happens-before 关系。现在我们来重点关注 2 happens-before 5,通过这个关系我们可以得出什么?
根据 happens-before 的定义中的一条:如果 A happens-before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。线程 A 先对共享变量 A 进行加一,由 2 happens-before 5 关系可知线程 A 的执行结果对线程 B 可见即线程 B 所读取到的 a 的值为 1。
# JVM 中锁的优化
在 JVM 中 monitorenter 和 monitorexit 字节码依赖于底层的操作系统的 Mutex Lock 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。不过在 jdk1.6 中对锁的实现引用了大量优化。
- 锁粗化:也就是减少不必要的紧连在一起的 unlock,lock 操作,将多个连续的锁扩展成一个范围更大的锁。
- 锁消除:通过运行 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁的保护,通过逃逸分析也可以在线程的 stack 上进行对象空间的分配(同时还可以减少 Heap 上的垃圾收集开销)。
- 轻量级锁:
- 这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态 (即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在 monitorenter 和 monitorexit 中只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒
- 偏向锁:是为了在无锁竞争的情况下避免在锁获取的过程中执行不必要的 CAS 原子指令,因为 CAS 原子指令虽然相对重量级锁开销较小,但还是存在非常可观的本地延迟。
- 适应性自旋锁:
- 当线程在获取轻量级锁的过程中执行 CAS 操作失败时,在进入与 monitor 相关联的操作系统重量级锁 (mutex semaphore) 前会进入忙等待 (Spinning) 然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该 monitor 关联的 semaphore (即互斥锁) 进入到阻塞状态。
# 锁的类型
jdk1.6 中 synchronized 同步锁,一共存在四种状态:无锁、偏向锁、轻量级锁、重量级锁。它会随着竞争情况逐渐升级,但是不可以降级,目的是为了提供获取锁和释放锁的效率。
# 自旋锁、自适应自旋锁
# 锁消除
锁消除是指虚拟机运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要判定依据来源于逃逸分析的数据支持。意思就是:JVM 会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那 JVM 就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。
当然在实际开发中,我们很清楚的知道哪些是线程独有的,不需要加同步锁,但是在 Java API 中有很多方法都是加了同步的,那么此时 JVM 会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。
比如如下操作:在操作 String 类型数据时,由于 String 是一个不可变类,对字符串的连接操作总是通过生成的新的 String 对象来进行的。因此 Javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前会使用 StringBuffer 对象的连续 append () 操作,在 JDK 1.5 及以后的版本中,会转化为 StringBuidler 对象的连续 append () 操作 (因为 String 是不可变类,是线程安全类,所以编译器会转为 StringBuilder 进行操作,在不会产出线程安全问题的同时,相对于使用 String 类型操作更高效,)。
public static String test03(String s1, String s2, String s3) { | |
String s = s1 + s2 + s3; | |
return s; | |
} |
# 锁粗化
原则上,在加同步锁时,尽可能将同步块的作用范围限制到尽量小的范围(在存在锁的同步竞争中,也可以使得等待锁的线程尽早拿到锁)。
但是如果存在一系列操作都对同一个对象反复加锁和解锁,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。
例如:
public static String test04(String s1, String s2, String s3) { | |
StringBuffer sb = new StringBuffer(); | |
sb.append(s1); | |
sb.append(s2); | |
sb.append(s3); | |
return sb.toString(); | |
} |
这里的连续 append () 操作就属于这种情况,JVM 会检测到这样一连串的操作都是对同一个对象加锁,那么 JVM 会将加锁同步的范围扩展(粗化)到整个一系列操作的外部,使整个一连串的 append () 操作只需要加一次所就可以了。
# 偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录中存储偏向的线程 ID,以后该线程在进入和退出同步块时,不需要进行 CAS 操作来加锁和解锁。只需要简单的测试一下对象头的 Mark Word
里是否存储着当前线程的偏向锁,如果成功,标识已经获取到了锁。
# 偏向锁的撤销
偏向锁使用一种等待竞争放锁机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的代码)。它会首先暂停拥有偏向锁的线程,让你检查持有偏向锁的线程是否还或者。如果线程不处于活动状态,直接将对象设置为无锁状态。如果线程还或者,JVM 会便利战阵中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
# 轻量级锁
# 重量级锁
# 锁的优缺点对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要 CAS 操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程成始终得不到锁竞争的线程,使用自旋会消耗 CPU 性能 | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步块执行速度较长 |
# synchronized 与 Lock
# synchronized 的缺陷
- 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁。试图获取锁的时候不能设定超时,不能中断一个正在使用的线程,相对而言,Lock 可以中断和设置超时。
- 不够灵活:加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),相对而言,读写锁更加灵活。
- 无法知道是否成功获取锁:相对而言,Lock 可以拿到状态,如果成功获取锁,如果获取锁失败。
# Lock 解决相应问题
Lock 类这里不做过多解释,主要看里面的 4 个方法:
lock()
: 加锁unlock()
: 解锁tryLock()
: 尝试获取锁,返回一个 boolean 值tryLock(long,TimeUtil)
: 尝试获取锁,可以设置超时
Synchronized 只有锁只与一个条件 (是否获取锁) 相关联,不灵活,后来 Condition与Lock的结合
解决了这个问题。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock 的 lockInterruptibly () 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像 synchronized 那样产生死锁了。
# 使用 Synchronized 有哪些需要注意的
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行速度,控制范围过大,编写代码容易出错。
- 避免死锁。
- 在能选择的情况下,既不要用 Lock 也不要用 synchronized 关键字,用 java.util.concurrent 包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用 synchronized 关键,因为代码量少,避免出错。
# synchronized 是公平锁么
synchronized 实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象
# volatile 详解
# 作用
- 防重排序:变量设置为 volatile 的类型,可以防止操作系统或者 JVM 进行重排序。
- 实现可见性:当一个线程更改了 volatile 类型变量,会立即刷新该变量值到主存中,其他线程需要使用该变量时,会去主存中重新读取该变量值,以此来避免可见性。
- 保证原子性:不能完全保证原子性,只能保证单次的读 / 写具有原子性。
# 实现原理
# volatile 可见性实现
volatile 变量的内存可见性是基于内存屏障 (Memory Barrier) 实现的。
内存屏障,又称内存栅栏,是一个 CPU 指令。在程序运行时,为了提高程序运行的性能,编译器和处理器会对指令进行重排序,JVM 为了保证在不用的编译器和 CPU 上有相同的运行结果,通过插入特定类型的内存屏障来禁止 + 特定的编译器和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
例如:
public class Test { | |
private volatile int a; | |
public void update() { | |
a = 1; | |
} | |
public static void main(String[] args) { | |
Test test = new Test(); | |
test.update(); | |
} | |
} |
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......
lock 前缀的指令在多核处理器下会引发两件事:
- 将当处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
# final 详解
# final 的基础使用
# 修饰类
当某个类被修饰为 final 时,就表明你不能继承该类。即这个类是不能有字类的。
注意:final 类中的所有方法都隐式为 final,因为无法覆盖他们,所以在 final 类中,给任何方法添加 final 关键字式没有任何意义的。
# 如何扩展 final 类?
在设计模式中,最重要的两种关系,一种是继承 / 实现,另一种是组合关系。
# 修饰方法
- final 修饰的方法是不可继承的。
- private 方法是隐式的 final。
- final 方法是可以重载的。
# 修饰参数
Java 允许在参数列表中以声明的方式将参数指明为 final,这意味这你无法在方法中更改参数引用所指向的对象。这个特性主要用来向匿名内部类传递数据。
# 修饰变量
-
编译期常量、非编译期常量。
public class Test {
// 编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
// 非编译期常量
Random r = new Random();
final int k = r.nextInt();
public static void main(String[] args) {
}
}
-
static final 必须在定义的时候进行赋值。
-
空白 final:声明为 final 类型,但是不给定值,在使用前被赋值。
### final 域重排序规则
#### final为基本类型
public class FinalDemo { | |
private int a; // 普通域 | |
private final int b; //final 域 | |
private static FinalDemo finalDemo; | |
public FinalDemo() { | |
a = 1; // 1. 写普通域 | |
b = 2; // 2. 写 final 域 | |
} | |
public static void writer() { | |
finalDemo = new FinalDemo(); | |
} | |
public static void reader() { | |
FinalDemo demo = finalDemo; // 3. 读对象引用 | |
int a = demo.a; //4. 读普通域 | |
int b = demo.b; //5. 读 final 域 | |
} | |
} |
# 写 final 域的重排序规则
禁止对 final 域的写重排序到构造函数之外,这个规则主要包含了两个方面:
-
JVM 会禁止编译器把 final 域的写重排序到构造函数之外;
-
编译器会在 final 域写之后,构造函数 return 之前,插入一个 storestore 屏障。这个屏障可以禁止处理器把 final 域的写重排序到构造函数之外。
由于 a,b 之间没有数据依赖性,普通域 a 可能会被重排序到构造函数之外,线程 B 就有可能读到的是普通变量 a 初始化之前的值(零值),这样就可能出现错误。而 final 域 b,根据重排序规则,会禁止 final 修饰的变量 b 重排序到构造函数之外,从而 b 就能够正确赋值,线程 B 就能读到 final 变量初始化之后的值。
因此,写 final 域的重排序规则可以确保,在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而不同于就不具有这个保障。
# 读 final 域重排序规则
规则为:在一个线程种,初次读对象引用和初次读对象包含的 final 域,JMM 会禁止这两个操作的重排序(注意,这个规则仅仅是针对处理器),处理器会在读 final 域的前面插入一个 LoadLoad 屏障。 实际上,读对象的引用和读该对象的 final 域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。
读对象的普通域被重排序到了读对象引用的前面就会出现线程 B 还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而 final 域的读操作就 “限定” 了在读 final 域变量前已经读到了该对象的引用,从而就可以避免这种情况。
读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读这个包含这个 final 域的对象的引用。
# final 为引用类型
# 写重排序规则
针对引用数据类型,final 域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个 final 修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。注意这里的是 “增加” 也就说前面对 final 基本数据类型的重排序规则在这里还是使用。
public class FinalReferenceDemo { | |
final int[] arrays; | |
private FinalReferenceDemo finalReferenceDemo; | |
public FinalReferenceDemo() { | |
arrays = new int[1]; //1 | |
arrays[0] = 1; //2 | |
} | |
public void writerOne() { | |
finalReferenceDemo = new FinalReferenceDemo(); //3 | |
} | |
public void writerTwo() { | |
arrays[0] = 2; //4 | |
} | |
public void reader() { | |
if (finalReferenceDemo != null) { //5 | |
int temp = finalReferenceDemo.arrays[0]; //6 | |
} | |
} | |
} |
由于对 final 域的写禁止重排序到构造方法外,因此 1 和 3 不能被重排序。由于一个 final 域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此 2 和 3 不能重排序。
# 读重排序规则
JMM 可以确保线程 C 至少能看到写线程 A 对 final 引用的对象的成员域的写入,即能看下 arrays [0] = 1,而写线程 B 对数组元素的写入可能看到可能看不到。JMM 不保证线程 B 的写入对线程 C 可见,线程 B 和线程 C 之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者 volatile