JUC-14-JMM&CAS&各种锁实战

JUC-14-JMM&CAS&各种锁实战

1. JMM 简介

mark

  • java内存模型(JMM)

  • JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

关于JMM的一些同步约定:

  • 线程解锁前,必须把共享变量立刻刷回主内存

  • 线程加锁前,必须读取主内存中最新的值到工作内存中

  • 加锁和解锁是同一把锁。

1.1 内存交互操作

 内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

    • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
    • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
    • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
    • load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
    • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
    • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
    • store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
    • write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

1.2 JMM对于内存操作的约定

JMM对这八种指令的使用,制定了如下规则:

    • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
    • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
    • 不允许一个线程将没有assign的数据从工作内存同步回主内存
    • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
    • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
    • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
    • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
    • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

2. Volatile 关键字

  • Volatile是Java虚拟机提供的轻量级的同步机制(不会引起线程上下文切换和调度)
    • 保证可见性
    • 不保证原子性
    • 禁止指令重排

2.1 保证可见性

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
import java.util.concurrent.TimeUnit;

public class Test01 {
// 主内存中的num = 0
private volatile static int num = 0;

public static void main(String[] args) { // main线程
// 不加volatile 这个线程就会死循环
new Thread(()->{
while (num == 0){

}
}).start();


try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 将工作内存修改num = 1
num = 1;

System.out.println(num);
}
}

原理:

  • 有volatile变量修饰的共享变量在写操作的时候会有两行汇编代码的产生(lock前缀)
    • 将当前处理器缓存行的数据写回到系统内存
    • 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效

原因:

  • CPU为了保证处理速度,CPU并不是直接和内存打交道,而是将系统内存缓存到CPU的高速缓存中(L1,L2等)
  • 如果多个CPU同时修改了缓存的值,并且同时写回到主内存就会出现问题
  • 在多个处理器的情况下,为了保证各个处理器的缓存是一致的。每个CPU会通过嗅探级制在总线上检查自己缓存的值是不是过期了,如果过期了,就直接将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改的时候,会重新从系统内存中读取到处理器的缓存中。

2.2 不保证原子性

  • 原子性:不可分割
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
// volatile 不保证原子性

public class Test02 {
private volatile static int num = 0;

public static void add(){
num++;
}

// 理论上num应该是20000
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}

// 停止条件
while (Thread.activeCount() >2 ){
Thread.yield();
}

System.out.println(num);
}
}

这里出现的根本原因是:num++; 不是一个原子性的操作

解决方案:如果不加lock和synchronized,怎么保证原子性?

  • 使用原子类来进行原子性操作(java.util.concurrent.atomic)

mark

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
import java.util.concurrent.atomic.AtomicInteger;

public class Test02 {

private volatile static AtomicInteger num = new AtomicInteger();

public static void add(){
// num++; // 运算不是一个原子性操作
num.getAndIncrement(); // AtomicInteger + 1的方法:原理CAS操作
}


// 理论上num应该是20000
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
add();
}
}).start();
}

// 停止条件
while (Thread.activeCount() >2 ){
Thread.yield();
}

System.out.println(num);
}
}

Atomic的底层是Unsafe类,这里Unsafe类是一个很特殊的存在!!!

2.3 禁止指令重排

  1. 什么是指令重排?

举例分析

1
2
3
4
5
6
7
8
9
10
int x = 1;  // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4

我们以前所理解的顺序是 1->2->3->4
但是计算机可能执行的顺序 1324 2134
但绝对不可能是 4123 这样的

我们都知道,为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。
线程A 线程B
x = a y = b
b = 1 a = 2

还有一种情况如上所示:

  • 如果a b x y默认值都是0的前提下
  • 正常结果是 x = 0 ; y = 0

变成如下情况:

线程A 线程B
b = 1 a = 2
x = a y = b

由于指令重排:

  • 可能出现 x = 2 , y = 1的诡异结果

mark

解决方案:volatile可以避免指令重排

  • 原理:内存屏障
    • 保证特定的操作执行顺序
    • 可以保证某些变量的可见性

mark

mark

“NO”表示禁止重排序。为了实现volatile内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM采取了保守策略:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障;
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障;
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障;
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障

StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;

StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序

LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序

LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序

3. 单例模式详解

禁止指令重排最主要用在单例模式上:

4. 深入理解CAS

4.1 底层原理

CAS是如下的简称:

  • compareAndSwap

  • 比较并交换

1
2
3
4
5
6
7
8
9
10
11
12
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

点进AtomicInteger源码来看一看:

mark

  • AtomicInteger 继承了 unsafe类
  • unsafe类的作用:Java操作内存

接下来我们来看看AtomicInteger的一个方法:getAndIncrement

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// getAndIncrement ,原子类的自增操作(相当于++操作)
atomicInteger.getAndIncrement();

public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

// 这里是一个自旋锁
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
// var1 要获取的对象
// var2 要获取对象的内存地址偏移量
// var5 获取的对象的内存地址偏移量
// var4 = 1 ,相当于var5 + 1
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

小结:

  • CAS:比较并交换(比较当前工作内存中的值和主内存中的值),如果这个值是期望的,那么则执行,否则不执行操作。
  • 缺点:
    1. 循环会耗时->(Long Adder)
    2. 一次性只能保证一个共享变量的原子性
    3. 存在ABA问题

4.2 CAS的ABA的问题

mark

举一个例子:

如上图所示:两个线程拿到A=1,右边的线程先拿到了A=1,并且把1改成了3,再把3改成了1。但是左边的线程毫不知情,虽然左边的线程拿到了1,但不是原来的1了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(2020);

// 对于平时写的sql来说:乐观锁!
atomicInteger.compareAndSet(2020,2021);
System.out.println(atomicInteger.get());

atomicInteger.compareAndSet(2021,2020);
System.out.println(atomicInteger.get());


atomicInteger.compareAndSet(2020,6666);
System.out.println(atomicInteger.get());


atomicInteger.getAndIncrement();

}

如果解决ABA的问题呢?

答:使用原子引用

4.3 原子引用

mark

原子引用:带版本号的原子操作(解决ABA问题)

对应思想:乐观锁。

实例:

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
    public static void main(String[] args) {
// AtomicInteger atomicInteger = new AtomicInteger(2020);

// AtomicStampedReference需要注意的是
// 如果泛型是包装类,注意对象的引用问题
AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(1,2);


// A线程
new Thread(()->{
// 获得版本号
int stamp = atomicStampedReference.getStamp();
System.out.println("A+"+stamp);

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}


// 版本号+1 类似于乐观锁操作
System.out.println(atomicStampedReference.compareAndSet(
1,
2,
atomicStampedReference.getStamp(),
atomicStampedReference.getStamp() + 1));


// 打印A2的版本号
System.out.println("A2+"+atomicStampedReference.getStamp());

// 修改回去
System.out.println(atomicStampedReference.compareAndSet(
2,
1,
atomicStampedReference.getStamp(),
atomicStampedReference.getStamp() + 1));

System.out.println("A3+"+atomicStampedReference.getStamp());

},"A").start();


new Thread(()->{
// 获得版本号
int stamp = atomicStampedReference.getStamp();
System.out.println("B+"+stamp);


try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(atomicStampedReference.compareAndSet(
1,
6,
atomicStampedReference.getStamp(),
atomicStampedReference.getStamp() + 1));


System.out.println("B2+"+atomicStampedReference.getStamp());

},"B").start();

}

5. 各种锁的理解

5.1 公平锁/非公平锁

  • 公平锁:不能插队,必须先来后到。
  • 非公平锁:可以插队,大家竞争。

默认:Synchronized和lock都是非公平锁

5.2 可重入锁

  • 可重入锁:拿到外面的锁就能拿到里面的锁(自动获得)
  1. 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
    public static void main(String[] args) {
Phone phone = new Phone();

new Thread(()->{
phone.sms();
},"A").start();


new Thread(()->{
phone.sms();
},"B").start();

}
}


class Phone{
public synchronized void sms(){
System.out.println(Thread.currentThread().getName()+"sms");
call();
}

public synchronized void call() {
System.out.println(Thread.currentThread().getName()+"call");
}

结果如下:

1
2
3
4
5
6
Asms
Acall
Bsms
Bcall

// 原理:执行完最里面的锁才释放这把锁(锁中有锁)
  1. lock版本
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
public class ReinLock {
public static void main(String[] args) {
Phone phone = new Phone();

new Thread(()->{
phone.sms();
},"A").start();


new Thread(()->{
phone.sms();
},"B").start();

}
}


class Phone{

Lock lock = new ReentrantLock();

public void sms(){

lock.lock();
lock.lock();
// 与Synchronized不同的是,这里是两把锁
// 还有要注意的是锁必须配对

try {
System.out.println(Thread.currentThread().getName()+"sms");
call();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
lock.unlock();
}

}

public void call() {

lock.lock();

try {
System.out.println(Thread.currentThread().getName()+"call");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}

}

5.3 自旋锁

  1. 自己编写的锁
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

// 自旋锁

import java.util.concurrent.atomic.AtomicReference;

public class spinLock {


// Thread 默认是null
AtomicReference<Thread> atomicReference = new AtomicReference<>();

// 加锁
public void myLock(){
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName()+"进入了mylock");

// 自旋锁
while (atomicReference.compareAndSet(null,thread)){


}
}


// 解锁
public void myUnLock(){
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName()+"进入了myUnlock");

// 解锁
atomicReference.compareAndSet(thread,null);

}
}
  1. 测试类
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
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TestSpinLock {
public static void main(String[] args) throws InterruptedException {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.unlock();


// 自己的锁:用CAS实现
spinLock lock = new spinLock();
lock.myLock();
lock.myUnLock();

new Thread(()->{
lock.myLock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.myUnLock();
}

},"t1").start();


TimeUnit.SECONDS.sleep(1);

new Thread(()->{

lock.myLock();
try {
TimeUnit.SECONDS.sleep(3);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.myUnLock();
}

},"t2").start();

}
}

结果:

1
2
3
4
t1进入了mylock
t2进入了mylock
t1进入了myUnlock
t2进入了myUnlock
  • 这里t1和t2都拿到了锁
  • 只有等t1释放锁之后,t2才能释放锁

5.4 死锁

  1. 死锁是什么?
  • 两个线程互相竞争对方的资源

mark

  1. 如何分析排除死锁?
  • 先来看一个死锁的例子
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
class Mythread implements Runnable{

private String lockA;
private String lockB;

public Mythread(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}



@Override
public void run() {
synchronized (lockA){
System.out.println(Thread.currentThread().getName()+"lock"+lockA);

try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (lockB){
System.out.println(Thread.currentThread().getName()+"lock"+lockB);
}
}

}
}


import java.util.concurrent.TimeUnit;

public class TestDeadLock {
public static void main(String[] args) {

String lockA = "lockA";
String lockB = "lockB";

new Thread(new Mythread(lockA,lockB),"T1").start();
new Thread(new Mythread(lockB,lockA),"T2").start();

}
}
  • 那么如何解决这个问题?
  1. 使用jps定位进程号 jps -l

mark

  1. jstack 进程号 找到死锁问题

mark

注意:面试中遇到问题如何排查??

  • 日志
  • 看一下堆栈信息
打赏
  • 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!
  • © 2019-2022 Zhuuu
  • PV: UV:

请我喝杯咖啡吧~

支付宝
微信