JVM第三篇,JVM的内存模型,完结撒花,★,°:.☆( ̄▽ ̄)/$:.°★ 。。
1. 内存模型
Java内存模型,Java Memory Module(JMM)的意思。它定义了一套多线程读写共享数据(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障。
2. 原子性
表示组成一个事务的多个数据库操作是一个不可分割的原子单元,只有所有的操作执行成功,整个事务才提交。事务中的任何一个数据库操作失败,已经执行的任何操作都必须被撤销,让数据库返回初始状态。例如银行转账操作。
问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?都有可能。
解决办法
synchronized,同步关键字
-
语法
1
2
3synchronized(对象){
要作为原子操作的代码,一个时刻只能有一个线程进入此代码块
} -
用synchronized解决并发问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26public class Solution {
static int i = 0;
static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (object) { // 锁住同一个对象
for (int j = 0; j < 5000; j++) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (object) {
for (int j = 0; j < 5000; j++) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
// 0
3. 可见性
1 | public class Solution { |
分析
-
初始状态下,t线程刚开始从主内存读取了run的值到工作内存。
-
因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己的工作内存中的高速缓存内,减少对主存中run的访问,提高效率。
-
1秒后,main线程修改了run的值,并同步至主存,而t是从自己的工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
解决办法
volatile,易变关键字。它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须要主存中获取它的值,线程操作volatile变量都是直接操作主存。体现可见性,不保证原子性,仅用在一个写线程、多个读线程的情况。
1 | volatile static boolean run = true; |
比较
synchronized语句块既可以保证代码的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized属于重量级操作,性能相对较低。
在刚刚的while循环内,写一个System.out.println(1);程序正常输出,查看源代码可知println使用了synchronized关键字。
4. 有序性
1 | int num = 0; |
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结
果为1 - 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过
了) - 还有一种情况,0,指令重排后,先执行了ready=true,然后去执行线程1
解决办法
volatile 修饰的变量,可以禁用指令重排
1 | volatile boolean ready = false; |
理解
同一线程内,JVM会在不影响正确性的前提下,可以调整语句的执行顺序。例如:
1 | static int i; |
可以看到,至于先执行i还是先执行j,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是:
1 | i = ...; // 较为耗时的操作 |
也可以是:
1 | j = ...; |
这种特性称之为“指令重排”,多线程下指令重排会影响正确性,比如著名的double-checked locking模式实现单例:
1 | public final class Singleton { |
以上的实现特点是:
- 懒惰实例化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
但在多线程环境下,上面的代码是有问题的, INSTANCE = new Singleton() 对应的字节码为:
1 | 0: new #2 // class cn/itcast/jvm/t4/Singleton |
47两行执行顺序不固定,可能发生指令重排的问题,JVM可能会优化为:先将引用地址赋值给INSTANCE变量后,再执行构造方法。别的线程可能拿到一个未初始化完毕的单例。
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。
happens-before
happens-before规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。
-
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
1
2
3
4
5
6
7
8
9
10
11
12static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start(); -
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
1
2
3
4
5
6
7volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start(); -
变量start前对变量的写,对该线程开始后对该变量的读可见
1
2
3
4
5static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start(); -
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()或t1.join()等待它结束)
1
2
3
4
5
6
7static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x); -
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x); -
对变量默认值( 0,false,null)的写,对其它线程对该变量的读可见
-
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
-
变量都是指成员变量或静态成员变量
5. CAS与原子类
5.1 CAS
CAS,Compare and Swap,它体现的是一种乐观锁的思想,比如多个线程要对一个共享的整形变量执行+1操作:
1 | // 需要不断尝试 |
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰,拿到最新的。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5.2 乐观锁与悲观锁
- CAS是基于乐观锁的思想,最乐观的估计,不怕别的线程来修改共享变量,计算改了也没关系,吃亏大不了重试。
- synchronized是基于悲观锁的思想,最悲观的估计,得防着其他线程来修改共享变量,我上锁了你们都别想改,我改完了解开锁,你们才有机会。
5.3 原子操作类
JUC(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如,AtomicInteger、AtomicBoolean等,它们底层就是采用CAS+volatile来实现的。
1 | // 创建原子整数对象 |
6. synchronized优化
Java HotSpot虚拟机中,每个对象都有对象头(包括class指针和Mark Word)。Mark Word平时存储这个对象的哈希码、分代年龄,当加锁时,这些信息就根据情况被替换为标记位、线程锁记录指针、重量级锁指针、线程ID等内容。
Synchonized是基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。Synchronized 用在方法上时,在字节码中是通过方法的 ACC_SYNCHRONIZED 标志来实现的。而代码块同步则是使用monitorenter和monitorexit指令实现的。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁,当获得对象的monitor以后,monitor内部的计数器就会自增(初始为0),当同一个线程再次获得monitor的时候,计数器会再次自增。当同一个线程执行monitorexit指令的时候,计数器会进行自减,当计数器为0的时候,monitor就会被释放,其他线程便可以获得monitor。
6.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。这就好比:学生(线程A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。 如果这期间有其它学生(线程B)来了,会告知(线程A)有并发访问,线程A 随即升级为重量级锁,进入重量级锁的流程。
而重量级锁就不是那么用课本占座那么简单了,可以想象线程A走之前,把座位用一个铁栅栏围起来。
每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。
6.2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时候需要进行锁膨胀,将轻量级锁变为重量级锁。
6.3 重量级锁
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免拥塞。
在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
- 就像等红灯时汽车要不要熄火,不熄火相当于自旋,熄火了相当于阻塞(等待时间长划算)。
- Java 7以后不能控制是否开启自旋功能。
6.4 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。Java 6引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW),是一个重量级的操作
- 访问对象的 hashCode 也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
- 撤销偏向和重偏向都是批量进行的,以类为单位
- 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
- 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
6.5 其他优化
-
减少上锁时间,同步代码块尽量短
-
减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如
- ConcurrentHashMap
- LongAdder分为base和cells两部分。没有并发争用的时候或者是cells数组正在初始化时候,会使用CAS来累加值到base,有并发争用,会初始化cells数组,数组有多少个cell,就允许有多少线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值。
- LinkedBlockingQueue入队和出队使用不同的锁,相对于LinkedBlockingQueueArray只有一个锁效率要高。
-
锁粗化
多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
1
new StringBuffer().append("a").append("b").append("c");
-
锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
-
读写分离
- CopyOnWriteArrayList
- ConyOnWriteSet
6.6 锁升级
无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
轻量级锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
重量级锁
重量级锁是依赖对象内部的monitor锁来实现。当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,需要从用户态转换到内核态,而转换状态是需要消耗很多时间。
线程A在进入同步代码块前,先检查MarkWord中的线程ID是否与当前线程ID一致,如果一致(还是线程A获取锁对象),则无需使用CAS来加锁、解锁。
如果不一致,再检查是否为偏向锁,如果不是,则自旋等待锁释放。
如果是,再检查该线程是否存在(偏向锁不会主动释放锁),如果不在,则设置线程ID为线程A的ID,此时依然是偏向锁。
如果还在,则暂停该线程,同时将锁标志位设置为00即轻量级锁(将MarkWord复制到该线程的栈帧中并将MarkWord设置为栈帧中锁记录)。线程A自旋等待锁释放。
如果自旋次数到了该线程还没有释放锁,或者该线程还在执行,线程A还在自旋等待,这时又有一个线程B过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
如果该线程释放锁,则会唤醒所有阻塞线程,重新竞争锁。