写在最前

  • 这部分的内容会在后续的JUC详细讲解

Java内存模型

  • 很多人将Java内存结构Java内存模型傻傻分不清,Java内存模型Java Memory Model(JMM)的意思
  • 简单地说,JMM定义了一套在多线程读写共享数据时(成员变量、数组),对数据的可见性、有序性和原子性的规则和保障

原子性

  • 原子性在前面的文章也提过,现在来简单回顾一下
  • 两个线程对初始值为0的静态变量,一个做自增,一个做自减,各做5000次,那么最终结果是0吗?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JMM01 {
static int i = 110;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i++;
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}

问题分析

  • 以上的结果可能是正数、负数、零。为什么呢?
  • 因为Java中对静态变量的自增、自减操作并不是原子操作
1
2
3
4
5
6
7
8
public class JMM02 {
static int i = 0;

public static void main(String[] args) {
i++;
i--;
}
}
  • 编译后的字节码文件
1
2
3
4
5
6
7
8
9
10
11
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: getstatic #2 // Field i:I
11: iconst_1
12: isub
13: putstatic #2 // Field i:I
16: return
  • 对于i++而言(注意i为静态常量),实际上会产生如下字节码指令
1
2
3
4
getstatic     #2    // 获取静态常量 i 的值
iconst_1 // 准备常量 1
iadd // 加法(如果是局部变量,则调用的是iinc)
putstatic #2 // 将修改后的值存入静态变量 i
  • 而对于i–而言,也是类似的操作
1
2
3
4
getstatic     #2    // 获取静态常量 i 的值
iconst_1 // 准备常量 1
isub // 减法
putstatic #2 // 将修改后的值存入静态变量 i
  • 在多线程环境下,这些指令可能会被CPU交错的执行,就会导致我们看到的结果出现问题
  • Java的内存模型如下,完成静态变量的自增、自减需要在主存与线程内存中进行数据交换
  • 出现负数的情况:
1
2
3
4
5
6
7
8
9
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
  • 出现正数的情况:
1
2
3
4
5
6
7
8
9
// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1

解决方法

  • 使用synchronized(同步关键字),语法如下
1
2
3
synchronized(obj) {
要作为原子操作的代码
}
  • 解决上面的问题,在i++i--操作处加锁
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
26
public class JMM03 {
static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
  • 可以把obj想象成一间房间(撤硕),线程t1、线程t2想象成两个人
  • 当线程t1执行到synchronized(obj)时,就好比t1进入了撤硕,并反手锁住了门,在门内执行i++操作
  • 此时如果t2也运行到了synchronized(obj),它发现门被锁住了,只能在门外等待
  • 当t1执行完synchronized块内的代码,此时才会解开门上的锁,从撤硕出来,t2线程此时才可以进入撤硕,并反手锁住门,执行它的i--操作
  • 注意:上例中的t1和t2线程都必须使用synchronized锁住同一个obj对象,如果t1锁住的是x对象,t2锁住的是y对象,就好比两个人进入了两个不同的撤硕,没法起到同步的效果
  • 最后,也建议将synchronized加锁的范围设置的大一些,刚刚的代码中,仅在i++操作上加锁,锁住了4条虚拟机指令,但是外层循环了5W次,那就要加锁解锁5W次,这样是比较耗时的,那么此时我们就可以直接在for循环上加锁,这样就只用解锁一次
1
2
3
4
5
6
7
Thread t1 = new Thread(() -> {
synchronized (obj) {
for (int j = 0; j < 5000; j++) {
i++;
}
}
});

可见性

  • 可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改的结果。在单线程环境下,修改变量的值和读取变量的值都是在同一个线程内进行的,所以不存在可见性问题。但是在多线程环境下,由于每个线程都有自己的缓存,所以可能出现一个线程修改了共享变量的值,但是其他线程还是看到原来的旧值的情况。

退不出的循环

  • 先来看一个现象,main线程对run变量的修改,对于t线程不可见,导致t线程无法停止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JMM04 {

static boolean run = true;

public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
//TODO
}
});
t.start();
Thread.sleep(1000); // 休眠 1 秒
run = false; // 此时将run改为false,按理说上面的while循环应该会结束
}
}
  • 那这是为什么呢?我们来分析一下(提示:联想一下上篇文章的JIT优化)
    1. 初始状态:t线程刚开始就从主存读取到了run的值到工作内存
    2. 因为t线程要频繁的从主存中读取run的值,JIT编译器会将run的值缓存至自己工作内存的高速缓存中,减少对主存中run的访问,提高效率
    3. 1秒过后,main线程修改了run值,并同步至主存,但是t现在已经是从自己工作内存的高速缓存中读取的run,结果永远是true

解决方法

  • 可见性问题的方法是通过使用volatile关键字来声明共享变量。在使用了volatile关键字声明的共享变量上进行读写操作时,JVM会保证所有线程都能够看到该变量的最新值,从而解决可见性问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class JMM04 {

volatile static boolean run = true;

public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
//TODO
}
});
t.start();
Thread.sleep(1000);
run = false;
}
}
  • 此时程序运行1秒后就会停下来了
  • synchronized语句块既可以保证代码的原子性,同时也可以保证代码块内变量的可见性。但缺点是synchronized属于重量级操作,性能相对较低
  • 如果在前面示例的死循环中,加入一条输出指令System.out.println()会发现,即使不加volatile修饰符,线程t也正确看到run变量的修改了,这是为什么呢?
    • 因为System.out.println()语句具有同步锁的效果,它会强制刷新CPU缓存,从而强制线程从主内存中读取变量的值。这与volatile的作用相似,可以保证线程获取到最新的变量值。
    1
    2
    3
    4
    5
    6
    public void println(int x){
    synchronized(this) {
    print(x);
    newLine( );
    }
    }

有序性

诡异的结果

  • 先来看一段代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int num = 0;
boolean ready = false;

// 线程1 执行此方法
public void method1(Person p) {
if(ready) {
p.age = num + num;
} else {
p.age = 1;
}
}

// 线程2 执行此方法
public void method2(Person p) {
num = 2;
ready = true;
}
  • Person是一个对象,有一个属性age用来保存结果,那么上面的代码会有几种可能?

    1. 线程1先执行,此时ready = false,进入else分支,结果是1
    2. 线程2先执行,num = 2ready = true,线程1执行是,ready = true,执行if分支,同时num = 2,结果是4
    3. 线程2先执行,num = 2,还没来得及执行ready = true,此时线程1执行,ready = false,进入else分支,结果是1
  • 但是其实还有一种可能,结果是0

    • 这种情况下:线程2先执行ready = true,切回到线程1,进入if分支,相加为0,再切回线程2执行num = 2
  • 这种现象叫:指令重排

    • 指令重排是指在编译器或者JIT编译器优化过程中,为了提高程序的性能而重新排列指令的执行顺序,以便在运行时更加高效地执行。
    • 指令重排并不会改变程序的语义,但它可能会改变程序的执行顺序,从而导致程序出现错误或异常。
    • 指令重排需要通过大量测试才能发现,借助java并发压测工具jcstress
    • 在JVM中,指令重排主要有以下三种类型:
      1. 编译器重排:编译器在生成目标代码时对指令进行重排,以提高代码的性能。
      2. 运行时重排:JIT编译器在运行时对字节码进行优化,对指令进行重排,以提高程序的性能。
      3. 处理器重排:现代处理器具有乱序执行的能力,可以根据需要重新排列指令的执行顺序,以提高指令的执行效率。
    • 指令重排的好处是可以提高程序的性能,但也有风险。如果重排不当,可能会导致程序出现错误或异常。为了避免这种情况,JVM提供了一些机制,例如volatile关键字、synchronized关键字、final关键字等,以保证程序的正确性。
  • 运行如下maven命令

1
mvn archetype:generate -DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=com.demo.jmm -DartifactId=com.demo.jmm.my-test-project -Dversion=1.0-SNAPSHOT

  • 修改生成的测试方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
  • 执行maven clean install,生成jar包
  • 使用java -jar命令启动测试,结果如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.

2 matching test results.
[OK] com.demo.jmm.ConcurrencyTest
(JVM args: [-XX:-TieredCompilation])
Observed state Occurrences Expectation Interpretation
0 4,118 ACCEPTABLE_INTERESTING !!!!
1 100,677,962 ACCEPTABLE ok
4 65,352,081 ACCEPTABLE ok

[OK] com.demo.jmm.ConcurrencyTest
(JVM args: [])
Observed state Occurrences Expectation Interpretation
0 4,446 ACCEPTABLE_INTERESTING !!!!
1 70,399,953 ACCEPTABLE ok
4 64,934,892 ACCEPTABLE ok
  • 可以看到,出现结果为0的次数有4118次,虽然次数相对较少,但毕竟还是出现了

解决方法

  • 使用volatile修饰的变量,可以禁用指令重排
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
volatile boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
  • 结果如下
1
2
3
4
*** INTERESTING tests
Some interesting behaviors observed. This is for the plain curiosity.

0 matching test results.

有序性理解

  • JVM会在不影响正确性的前提下,调整语句的执行顺序,来看一下下面的代码
1
2
3
4
5
6
static int i;
static int j;

//在某个线程内执行如下赋值操作(单线程)
i = ...; // 较为耗时的操作
j = ...l // 简单的 操作
  • 可以看到,不管是先执行i还是先执行j,对最终的结果都不会产生影响,所以上面两条语句的执行顺序可以任意的排列组合
  • 这种特性被称之为指令重排,多线程下的指令重排会影响正确性,例如著名的double-checked-locking模式实现单例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Singleton {
private Singleton(){}
private static Singleton INSTANCE = null;
public static Singleton getInstance(){
if (INSTANCE == null){
synchronized (Singleton.class){
if (INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
  • 以上的实现的特点是
    1. 懒惰实例化
    2. 首次使用getInstance()才使用synchronized加锁,后续使用时无需加锁
  • 但是在多线程环境下,上面的代码是有问题的,INSTANCE = new Singleton();对应的字节码如下
1
2
3
4
0: new #2               // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field
  • 其中47 两步的顺序不是固定的,也许jvm会优化为:先将引用地址赋给INSTANCE变量后,再执行构造方法,如果两个线程t1、t2按如下时间序列执行
    1. 时间1 t1 线程执行到 INSTANCE = new Singleton();
    2. 时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
    3. 时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
    4. 时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接返回 INSTANCE
    5. 时间5 t1 线程执行Singleton的构造方法(4 处)
  • 此时t1还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的将是一个未完成初始化的单例
  • 对INSTANCE使用volatile修饰即可,可以禁用指令重排

happens-before

  • happens-before规定了哪些写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结。抛开以下happens-before规则,JMM不能保证一个线程对共享变量的,对于其他线程对该共享变量的可见
  1. 线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    static int x;
    static Object m = new Object();

    public static void main(String[] args) {
    new Thread(() -> {
    synchronized (m) {
    x = 10;
    }
    }, "t1").start();
    new Thread(() -> {
    synchronized (m) {
    System.out.println(x);
    }
    }, "t2").start();
    }
    • 在线程t2中,当获取了对象m的锁之后,线程可以读取到线程t1对变量x的写入结果。
  2. 线程对volatile变量的写,对接下来其他线程对该变量的读可见
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    volatile static int x;

    public static void main(String[] args) {
    new Thread(() -> {
    x = 10;
    }, "t1").start();
    new Thread(() -> {
    System.out.println(x);
    }, "t2").start();
    }
    • 在线程t2中,当读取变量x的值时,可以看到线程t1对变量x的最新写入结果,而不会读取到变量x的旧值。
  3. 线程start前对变量的写,对该线程开始后对该变量的读可见
    1
    2
    3
    4
    5
    6
    7
    8
    static int x;

    public static void main(String[] args) {
    x = 10;
    new Thread(() -> {
    System.out.println(x);
    }, "t2").start();
    }
  4. 线程结束前对变量的写,对其他线程得知它结束后的读可见
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    static int x;

    public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(()->{
    x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
    }
    • 当主线程中读取变量x的值时,可以看到线程t1对变量x的写入结果。
  5. 线程 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
    24
    25
    26
    static 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);
    }
    • 以上代码创建了两个线程t1和t2。线程t2在一个无限循环中不断检查自身的中断状态,如果发现自己被打断则打印变量x的值并跳出循环,线程t1会在1秒后修改变量x的值并打断线程t2。
    • 线程t1在打断线程t2之前对变量x的写操作对于其他线程得知线程t2被打断后的读操作可见。在本例中,线程t1在修改变量x的值并打断线程t2之前会先睡眠1秒,因此线程t2的循环会在线程t1修改变量x的值之后才会被打断。此时,线程t2中对变量x的读操作就能看到线程t1对变量x的修改。

CAS与原子类

  • CAS即Compare And Swap,它体现的是一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 需要不断尝试
while(true) {
int 旧值 = 共享变量; // 比如当前共享变量是 0
int 结果 = 旧值 + 1; // 在旧值的基础上 +1 ,结果是 1

/*
此时如果别的线程将共享变量改为了 5,那么本线程的正确结果 1 就作废了
此时compareAndSwap 返回 false,重新尝试
直到compareAndSwap 返回 true,表示本线程做修改的同时,别的线程没有干扰
*/
if(compareAndSwap(旧值, 结果)) {
// 比较旧值和当前共享变量是否相等
// 成功,退出循环
}
}
  • 获取共享变量时,为了保证该变量的可见性需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核CPU的场景下
    • 因为没有使用synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
  • CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令,下面是直接使用Unsafe对象进行线程安全保护的一个例子
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class JMM06 {
public static void main(String[] args) throws InterruptedException {
DataContainer dc = new DataContainer();
int count = 5;
Thread t1 = new Thread(() -> {
for (int i = 0; i < count; i++) {
dc.increase();
}
});
t1.start();
t1.join();
System.out.println(dc.getData());
}
}

class DataContainer {
private volatile int data;
static final Unsafe unsafe;
static final long DATA_OFFSET;

static {
try {
// Unsafe 对象不能直接调用,只能通过反射获得
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
}
try {
// data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
} catch (NoSuchFieldException e) {
throw new Error(e);
}
}

public void increase() {
int oldValue;
while (true) {
// 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
oldValue = data;
// cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue +
1)) {
return;
}
}
}

public void decrease() {
int oldValue;
while (true) {
oldValue = data;
if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue -
1)) {
return;
}
}
}

public int getData() {
return data;
}
}

乐观锁与悲观锁

  • CAS是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就散改了也没关系,我吃亏点再重试呗
  • synchronized是基于悲观锁的思想:最悲观的估计,得防着其他线程来修改共享变量,我上了锁你们都别想改,我改完了再解开锁,你们才有机会来

原子操作类

  • JUC(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean等,它们底层就是采用CAS技术+volatile来实现的
  • 可以使用AtomicInteger改写之前的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class JMM07 {
// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndIncrement(); // 获取并且自增 i++
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); // 获取并且自增 i--
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 最终的结果总是0
System.out.println(i);
}
}

synchronized优化

  • Java HotSpot虚拟机中,每个对象都有对象头(包括class指针和Mark Word),Mark Word平时存储这个对象的哈希码分代年龄,当加锁时,这些信息就根据情况被替换为标记位线程锁记录指针重量级锁指针线程ID等内容

轻量级锁

  • 如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么就可以使用轻量级锁来优化,就好比
    • 学生A(线程A)用课本占座,上了半节课就出门了(CPU时间到了),回来一看,发现课本还在,说明没有竞争,继续上他的课
    • 如果此时其他学生B(线程B)来了,会告知学生A(线程A)有并发访问,线程A随即升级为重量级锁,进入重量级锁的流程
    • 而重量级锁就不是用课本占座那么简单了,在学生A走之前,把座位用铁栅栏围了起来
    • 假设有两个方法同步块,利用同一个对象加锁
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    static Object obj = new Object();
    public static void method1(){
    synchronized(obj) {
    // 同步块 A
    method2();
    }
    public static void method2(){
    synchronized(obj) {
    // 同步块 B
    }
    }
  • 每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
线程 1 对象 Mark Word 线程 2
访问同步块 A,把 Mark 复制到线程 1 的锁记录 01(无锁) -
CAS 修改 Mark 为线程 1 锁记录地址 01(无锁) -
成功(加锁) 00(轻量锁)线程 1 锁记录地址 -
执行同步块 A 00(轻量锁)线程 1 锁记录地址 -
访问同步块 B,把 Mark 复制到线程 1 的锁记录 00(轻量锁)线程 1 锁记录地址 -
CAS 修改 Mark 为线程 1 锁记录地址 00(轻量锁)线程 1 锁记录地址 -
失败(发现是自己的锁) 00(轻量锁)线程 1 锁记录地址 -
锁重入 00(轻量锁)线程 1 锁记录地址 -
执行同步块 B 00(轻量锁)线程 1 锁记录地址 -
同步块 B 执行完毕 00(轻量锁)线程 1 锁记录地址 -
同步块 A 执行完毕 00(轻量锁)线程 1 锁记录地址 -
成功(解锁) 00(轻量锁)线程 1 锁记录地址 -
- 01(无锁) -
- 01(无锁) 访问同步块 A,把 Mark 复制到线程 2 的锁记录
- 01(无锁) CAS 修改 Mark 为线程 2 锁记录地址
- 00(轻量锁)线程 2锁记录地址 成功(加锁)
-

锁膨胀

  • 如果在尝试加轻量级锁的过程中,CAS操作无法完成,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时就需要进行锁膨胀,将轻量级锁变为重量级锁
1
2
3
4
5
6
7
8
9
public static void method1(){
synchronized(obj) {
// 同步块 A
}
public static void method2(){
synchronized(obj) {
// 同步块 B
}
}
线程 1 对象 Mark Word 线程 2
访问同步块 A,把 Mark 复制到线程 1 的锁记录 01(无锁) -
CAS 修改 Mark 为线程 1 锁记录地址 01(无锁) -
成功(加锁) 00(轻量锁)线程 1 锁记录地址 -
执行同步块 00(轻量锁)线程 1 锁记录地址 -
执行同步块 00(轻量锁)线程 1 锁记录地址 访问同步块,把 Mark 复制到线程 2
执行同步块 00(轻量锁)线程 1 锁记录地址 CAS 修改 Mark 为线程 2 锁记录地址
执行同步块 00(轻量锁)线程 1 锁记录地址 失败(发现别人已经占了锁)
执行同步块 00(轻量锁)线程 1 锁记录地址 CAS 修改 Mark 为重量锁
执行同步块 10(重量锁)重量锁指针 阻塞中
执行完毕 10(重量锁)重量锁指针 阻塞中
失败(解锁) 10(重量锁)重量锁指针 阻塞中
释放重量锁,唤起阻塞线程竞争 01(无锁) 阻塞中
- 10(重量锁) 竞争重量锁
- 10(重量锁) 成功(加锁)
-

重量锁

  • 重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
    • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
    • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
    • Java 7 之后不能控制是否开启自旋功能
  • 自旋重试成功的情况
线程 1 (cpu 1 上) 对象 Mark 线程 2 (cpu 2 上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行完毕 10(重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10(重量锁)重量锁指针 成功(加锁)
- 10(重量锁)重量锁指针 执行同步块
-
  • 自旋重试失败的情况
线程 1(cpu 1 上) 对象 Mark 线程 2(cpu 2 上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 阻塞
-

偏向锁

  • 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.
    • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
    • 访问对象的 hashCode 也会撤销偏向锁
    • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,
    • 重偏向会重置对象的 Thread ID
    • 撤销偏向和重偏向都是批量进行的,以类为单位
    • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
    • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
  • 假设有两个方法同步块,利用同一个对象加锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static Object obj = new Object();

public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}

public static void method2() {
synchronized(obj) {
// 同步块 B
}
}
线程 1 对象 Mark
访问同步块 A,检查 Mark 中是否有线程 ID 101(无锁可偏向)
尝试加偏向锁 101(无锁可偏向)对象 hashCode
成功 101(无锁可偏向)线程ID
执行同步块 A 101(无锁可偏向)线程ID
访问同步块 B,检查 Mark 中是否有线程 ID 101(无锁可偏向)线程ID
是自己的线程 ID,锁是自己的,无需做更多操作 101(无锁可偏向)线程ID
执行同步块 B 101(无锁可偏向)线程ID
执行完毕 101(无锁可偏向)对象 hashCode

其他优化

  1. 减少上锁时间

    • 同步代码块中尽量短
  2. 减少锁的粒度

    • 将一个锁拆分为多个锁提高并发度,例如
      • ConcurrentHashMap
      • LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
      • LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
  3. 锁粗化

    • 多次循环进入同步块不如同步块内多次循环
    • 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)
    1
    new StringBuffer().append("a").append("b").append("c");
  4. 锁消除

    • JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
  5. 读写分离

    • CopyOnWriteArrayList
    • ConyOnWriteSet