咸鱼

咸鱼是以盐腌渍后,晒干的鱼

0%

黑马程序员JMM学习笔记

本文是【黑马程序员JVM完整教程,全网超高评价,全程干货不拖沓-哔哩哔哩】教学视频学习所做的笔记

四、什么是 JMM

定义

Java内存模型 Java Memory Model ( JMM )

简单来说,JMM定义了一套在多线程读写共享内存时(成员变量、数组),对数据的可见性、有序性、原子性的规则和保障。

1、原子性

所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行,多个操作是一个不可以分割的整体。

问题:两个线程对初始值为0的静态变量操作:一个做自增,一个做自减,各做5000次,结果会是0吗?

代码:

结果:以上的结果可能是正数、负数、零。因为Java中对静态变量的自增自减并不是原子性操作。

看自增自减的字节码分析:

而Java 的内存模式如下图,完成静态变量的自增、自减操作需要在 “主内存” 和 “线程内存” 进行数据交换

如果是单线程,以上8行代码是顺序执行(不会交错)没有问题:

但多线程下,这8行代码可能会交错执行:

用synchronized关键字通过代码,进行原子性操作:

优化代码:

2、可见性

看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static boolean run = true;
public static void main(String[] args) throws InterruptedException {

Thread t = new Thread(() -> {
while (run){
// ...
}
});
t.start();

Thread.sleep(1000);
// 线程t不会如预想的停下来
run = false;
}

线程 t 不会如预想的停下来,这就是由于线程的高速缓存内存导致了 可见性 问题,

(注:这种模式在Android平台上线程t能停止,但这里分析的是非Android平台)

分析以上代码:

初始状态,t 线程从 “主内存” 读取 run 的值到 “工作内存”。

因为 t 线程要频繁的从主内存读取 run 的值,JIT编译器会将 run 的值缓存至“工作内存”中的高速缓存中,减少对主内存中 run 的访问,提高访问效率。

Thread.sleep(1000); 执行结束后,main线程修改 run 的值,并同步至主内存, 而 t 线程是从自己工作内存中的高速缓存中读取这个 run 的值,结果永远是旧值。

解决办法:给静态变量 run 加一个关键字 volatile 修饰。

总结:以上是一个可见性的问题。

volatile 可以用来修饰 成员变量 和 静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主内存中获取变量的值,线程操作 “volatile 变量” 都是直接操作“主内存”。

volatile 可见性 的特性是指在多个线程之间,保证一个线程对 volatile 变量的修改对另外一个线程可见。

volatile 不能保证原子性,仅用在“一个线程” ,“多个线程” 的情况。

synchronized 语句块即可保证代码块的原子性 ,也同时保证代码块内的变量 可见性 。但缺点是他是重量级操作,性能相对更低。

看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static boolean run = true;
public static void main(String[] args) throws InterruptedException {

Thread t = new Thread(() -> {
while (run){
// System.out.println() 内部使用了synchronized
// 所以也可以保证对 run 的可见性
System.out.println(1)
}
});
t.start();

Thread.sleep(1000);
// 线程t能停下来
run = false;
}

3、有序性

同一个线程内,JVM会在不影响正确性的情况下,调整语句执行的顺序,看如下代码:

1
2
3
4
5
6
static int i;
static int j;

//在某线程内执行如下赋值操作
i = ...;//这是一个较为耗时操作
j = ...;

可以看到,i j 的赋值对结果不会产生影响。所以顺序可以是:

1
2
i = ...;//这是一个较为耗时操作
j = ...;

也可以是

1
2
j = ...;
i = ...;//这是一个较为耗时操作

这种特性称为 【指令重排】,多线程下【指令重排】会影响正确性,例如著名的 double-checked locking 模式实现单例。

看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final class Singleton {
private Singleton(){}
private static Singleton INSTANCE = null;
public static Singleton getInstance(){

//示例没创建,才会进入内部的 synchronized 代码块
if(INSTANCE == null){
synchronized (Singleton.class){

// 也许有其他线程已经创建了实例,所以在判断一次
if(INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

这段代码的特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁。

但在多线程下,这是有问题的,因为 INSTANCE = new Singleton(); 实例化会有【指令重排】问题。

new Singleton() 分析字节码:

  • 4: 构造方法
  • 7:将引用地址复制给 INSTANCE 变量

其中 第 4第 7 的顺序是不固定的,在多线程下,可能会按照如下顺序执行:

这样的话,t1 尚未完全执行构造方法,如果构造方法中需要执行很多初始化操作,那么 t2 线程拿到的是一个未完全初始化的单例。

解决有序性: 对 INSTANCE 使用 volatile 修饰。

总结: volatile 可以禁止指令重排。

4、happens-before 规则

这是总结,可以不看,理解上面的三节基本就懂了。

happens-before 规定了哪些写操作对其他线程的读操作可见,它是可见性和有序性的一套规则总结。

  • 线程解锁 m 之前,对变量的赋值,对于接下来“对 m 加锁”的“其他线程”对“该变量”可见

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    static 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
    7
    8
    volatile static int x;
    new Thread(()->{
    x = 10;
    },"t1").start();

    new Thread(()->{
    System.out.println(x);
    },"t2").start();
  • 线程 start 前对变量的赋值,对该线程开始后对改变的可见

    1
    2
    3
    4
    5
    6
    7
    static int x;

    x = 10;

    new Thread(()->{
    System.out.println(x);
    },"t2").start();
  • 线程结束前对变量的赋值,对其他线程得知它结束后读可见(比如其他线程调用 t1.isAlive() 或者 t1.join()等待它结束)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static 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
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public class Demo5 {

    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");
    t2.start();


    while (!t2.isInterrupted()){
    Thread.yield();
    }
    System.out.println(x);
    }
    }
  • 对变量默认值(0,false,null)的赋值,对其他线程时可见的

  • 具有传递性,如果 x hb(happens-before) -> y 并且 y hb -> z 那么有 x hb -> z

5、CAS与原子类

5.1 CAS

CAS (Compare and Swap),它体现的一种乐观锁的思想(无锁并发),比如多线程对一个共享Int变量做加1操作:

synchronized 是悲观锁思想

1
2
3
4
5
6
7
8
9
10
11
12
13
//死循环,不断尝试
while(true){

int 旧值 = 共享变量; //假如取到的值是0
int 结果 = 旧值 + 1;// 0 + 1 = 1
/**
1. 执行到这里,如果其他线程修改 “共享变量”值为5,则本线程的计算结果作废,compareAndSwap会返回false;重新进入循环尝试。
2. 直到compareAndSwap返回true时,表示本线程做修改的同时,其他线程没有做任何修改(干扰)
*/
if( compareAndSwap(旧值,结果)){
//成功,退出循环
}
}

获取“共享变量”时,为了保证它的可见性,需要用 volatile 修饰。

结合CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核CPU(死循环的原因)场景下。

  • 因为没有使用 synchronized,线程不会阻塞,这是效果提升的因素之一
  • 如果竞争激烈,可以想到重试必然频繁发送,反而效率会受到影响

CAS 底层依赖一个 Unsafe 类来直接调用操作系统底层的 CAS 指令

5.2 原子操作类

java.util.concurrent 中提供了原子操作类,可以提供线程安全的操作.

例如:AtomicInteger、AtomicBoolean等,它们底层就是采用CAS + volatile实现。

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 AtomicDemo {

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++
// i.incrementAndGet(); //自增并获取 ++i
}
});

Thread t2 = new Thread(()->{
for (int j = 0; j < 5000; j++) {
i.getAndDecrement(); //获取并自减 i--
}
});

t1.start();
t2.start();

t1.join();
t2.join();

System.out.println(i);
}
}

6、synchronized 优化

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

synchronized本身是一个重量级的操作,但JDK一直为synchronized做优化,比如轻量级锁等。

6.1 轻量级锁

每个线程栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

以下分析上面代码块轻量级锁加锁的过程(多个线程访问时间错开)

6.2 锁膨胀

如果在尝试加轻量级锁的过程中CAS操作失败,有一种情况是有其他线程为对象加上轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁升级为重量级锁。

看示例:

1
2
3
4
5
6
static Object obj = new Object();
public static void method1(){
synchronized(obj){
//同步代码块
}
}

分析:

6.3 重量级锁

自旋:先不进入阻塞,尝试重试

重量级锁自旋优化

6.4 偏向锁

这个比较复杂,稍微理解就行,课程里面也是带过一下

6.5 其他优化