JVM之内存模型

Java 内存模型

很多人将java 内存结构java 内存模型傻傻分不清楚, java 内存模型是 Java Memory Model (JMM) 的意思。

关于它的权威解释, 请参考:这里<( ̄︶ ̄)↗[GO!]

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

1. 原子性

问题分析

**前题:**原子性在多线程环境经常需要考虑的问题, 下面来个例子简单回顾一下

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

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

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

例如对于 i++ 而言(i为静态变量), 实际会产生如下的 JVM 字节码指令:

getstatic   i  // 获取静态变量i的值
iconst_1       // 准备常量1
iadd           // 加法
putstatic   i  // 将修改后的值存入静态变量i

而对应 i-- 也是类似:

getstatic   i  // 获取静态变量i的值
iconst_1       // 准备常量1
isub           // 减法
putstatic   i  // 将修改后的值存入静态变量i 

而 Java 的内存模型如下, 完成静态变量的自增, 自减需要在主存和线程内存中进行数据交换:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JmvUgVF7-1655547709160)(C:\Users\86133\Pictures\JVM\内存模型\内存模型.png)]

如果是单线程, 以上8行代码的执行是顺序的:

getstatic   i  // 线程1 - 获取静态变量i的值 线程内i=0
iconst_1       // 线程1 - 准备常量1
iadd           // 线程1 - 自增 线程内i=1
putstatic   i  // 线程1 - 将修改后的值存入静态变量i 静态变量i=1
getstatic   i  // 线程1 - 获取静态变量i的值 线程内i=1
iconst_1       // 线程1 - 准备常量1
isub           // 线程1 - 自减 线程内 i=0
putstatic   i  // 线程1 - 将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行: 线程随机抢占CPU, CPU轮转调度将时间片分配给线程执行, 即线程在执行过程中可能被打断

出现负数的情况:

// 假设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

出现正数的情况:

// 假设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 (同步关键字) 解决

语法:

synchronized (对象) {
    要作为原子操作代码
}

使用 synchronized 解决上面的并发问题:

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 想象成两个人。

  1. 当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了房间, 并反手锁住了门, 在门内执行 count++ 代码。
  2. 这时候如果 t2 也运行到了 synchronized(obj) 时, 它发现门被锁住了, 只能在门外等候。
  3. 当 t1 执行完 synchronized{} 块内的代码, 这时候才会解开门上的锁, 从obj 房间出来。t2线程这时才可以进入 obj 房间, 反锁住门, 执行它的 count-- 代码。

2. 可见性

退不出的循环

先来看一个现象, main 线程对 run 变量的修改对于 t 线程不可见, 导致了 t线程无法停止:

// java 内存模型 —— 可见性问题
public class Test {
    static boolean run  = true;
 
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // ...
            }
        });
        t.start();
 
        Thread.sleep(1000);
        run = false; // 线程t 不会如预想的那样停下来
    }
}

分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vdQdgLtO-1655547709162)(C:\Users\86133\Pictures\JVM\内存模型\可见性-1.png)]

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

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tWaLXjUA-1655547709163)(C:\Users\86133\Pictures\JVM\内存模型\可见性-2.png)]

  3. 1 秒之后, main 线程修改了 run 的值, 并同步至主存, 而 t 是从自己的工作内存中的高速缓存中读取这个变量的值, 结果永远是旧值。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I6jPsV5d-1655547709164)(C:\Users\86133\Pictures\JVM\内存模型\可见性-3.png)]

解决方法

volatile (易变关键字)

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

public class Test {
    volatile static boolean run  = true;
 
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // ...
            }
        });
        t.start();
 
        Thread.sleep(1000);
        run = false; // 线程t正常停止
    }
}

前面例子体现的实际就是可见性, 它保证的是在多个线程之间, 一个线程对 volatile 变量的修改对另一个结程可见, 不能保证原子性, 应用场景: 仅用在一个写线程, 多个读线程的情况, 即内存屏障 MERS 消息一致性协议。

从字节码层面来看:

getstatic      run    // 线程 t 获取 run true
getstatic      run    // 线程 t 获取 run true
getstatic      run    // 线程 t 获取 run true
getstatic      run    // 线程 t 获取 run true
putstatic      run    // 线程 main 修改 run 为 false, 仅此一次
getstatic      run    // 线程 t 获取 run false

比较之前线程安全的例子:

// 假设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

注意

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

3. 有序性

诡异的结果

// java 内存模型 —— 有序性 问题
public class Test {
    int num = 0;
    boolean ready = false;
 
    // 线程1 执行此方法
    public void actor1(I_Result r){
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    // 线程2 执行此方法
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}

I_Result 是一个对象, 有一个属性 r1 用来保存结果, 问: 可能的结果有几种?

  • 情况1: 线程1先执行, 这时 ready = false, 所以进入 else 分支结果为1
  • 情况2: 线程2先执行 num = 2, 但没来得及执行 ready = true, 线程1执行, 还是进入 else 分支, 结果为1
  • 情况3: 线程2执行到 ready = true, 线程1 执行, 这回进入 if 分支, 结果为4 (因为 num 已经执行过了)

但其实还有另外一种情况, 那就是结果为 0, 这种情况是: 线程 2 先执行 ready = true, 切换到线程 1, 进入 if 分支, 结果为 0, 再回到线程 2, 执行 num = 2。可是这样不是我们的代码顺序啊, 我们的代码明明是先 num = 2, 再 ready = true 啊, 这是为什么呢?

这种现象叫做指令重排, 是 JIT 编译器在运行时的一些优化, 这个现象需要通过大量测试才能复现:

我们使用 Java 并发压测工具 Jcstress

添加 maven 依赖:

<dependency>
    <groupId>org.openjdk.jcstress</groupId>
    <artifactId>jcstress-core</artifactId>
    <version>0.5</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jcstress</groupId>
    <artifactId>jcstress-samples</artifactId>
    <version>0.5</version>
</dependency>

测试类代码:

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class Test {
    int num = 0;
    boolean ready = false;

    // 线程1 执行此方法
    @Actor
    public void actor1(I_Result r){
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    // 线程2 执行此方法
    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}

执行

mvn clean install
java -jar target/jcstress.jar

看一下其中的一次测试结果:

61 matching test results.
      [OK] com.ayu.Test
    (JVM args: [-XX:-TieredCompilation])
  Observed state   Occurrences              Expectation  Interpretation
               0         3,717   ACCEPTABLE_INTERESTING  !!!!
               1    80,005,503               ACCEPTABLE  ok
               4    47,367,011               ACCEPTABLE  ok

      [OK] com.ayu.Test
    (JVM args: [])
  Observed state   Occurrences              Expectation  Interpretation
               0         3,254   ACCEPTABLE_INTERESTING  !!!!
               1    39,597,387               ACCEPTABLE  ok
               4    48,423,550               ACCEPTABLE  ok

可以看到, 结果为 0 的次数有 3000 多次。

解决方法

volatile 修饰的变量, 可以禁用指令重排

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class Test {
    int num = 0;
    volatile boolean ready = false;

    // 线程1 执行此方法
    @Actor
    public void actor1(I_Result r){
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    // 线程2 执行此方法
    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}

再次执行测试

*** INTERESTING tests
	Some interesting behaviors observed. This is for the plain curiosity.
	0 matching test results.

指令重排

同一个线程内, JVM 会在不影响正确性的前提下, 可以调整语句的执行顺序, 思考下面一段代码

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

可以看到, 至于是先执行 i 还是先执行 j, 对最终的结果不会产生影响。所以, 上面代码真正执行时, 既可以是

i = ...; // 较为耗时的操作
j = ...; 

也可以是

j = ...; 
i = ...; // 较为耗时的操作

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

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() 对应的字节码为:

0: new           #3  // class com/jvm/t12_juc/T05_Juc_Singleton
3: dup
4: invokespecial #4  // Method "<init>":()V
7: putstatic     #2  // Field INSTANCE:Lcom/jvm/t12_juc/T05_Juc_Singleton;

其中 4 7 两步的顺序不是固定的, 也许 JVM 会优化为:先将引用地址赋值给 INSTANCE 变量后, 再执行构造方法, 如果两个线程t1, t2 按如下时间序列执行:

时间1    t1 线程执行到 INSTANCE = new Singleton();
时间2    t1 线程分配空间,Singleton 对象生成了引用地址 (0) 
时间3    t1 线程将引用地址赋值给 INSTANCE, 这时 INSTANCE != null  (7) 
时间4    t2 线程进入 getInstance() 方法, 发现 INSTANCE != null (synchronized 块外), 直接返回 INSTANCE
时间5    t1 线程执行Singleton 的构造方法 (4) 

这时 t1 还未完全将构造方法执行完毕, 如果在构造方法中要执行很多初始化操作, 那么 t2 拿到的是将 是一个未初始化完毕的单例。

对 INSTANCE 使用 volatile 修饰即可, 可以禁用指令重排, 但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

happens - before

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

  • 线程对 volatile 变量的写, 对接下来其它线程对该变量的读可见

    volatile static int x;
     
    new Thread(() -> {
        x = 10;
    }, "t1").start();
     
    new Thread(() -> {
        System.out.println(x);
    }, "t2").start();
    
  • 线程解锁 m 之前对变量的写, 对于接下来对 m 加锁的其它线程对该变量的读可见

    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();
    
  • 线程 start 前对变量的写, 对该线程开始后对该变量的读可见

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

    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)

    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);
    }
    
  • 对变量默认值 (0, false, null) 的写, 对其它线程对该变量的读可见

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

4. CAS 与 原子类

CAS

CAS 即 Compare and Swap, 它体现的一种乐观锁思想, 比如过个线程要对一个共享的整数变量执行 +1 操作:

// 需要不断尝试
while (true) {
    int 旧值 = 共享变量; // 比如拿到了当前值 0
    int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加1,  正确结果是 1

    /*
    	这时候如果别的线程把共享变量改成了 5, 本线程的正确结果 1 就作废了, 这时候
    	compareAndSwap 返回 false, 重新尝试, 直到:
    	compareAndSwap 返回 true, 表示我本线程做修改的同时, 别的线程没有干扰
    */
    if (compareAndSwap(旧值, 结果)) {
        // 成功, 退出循环
    }
}

获取共享变量时, 为了保证该变量的可见性, 需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发, 适用于竞争不激烈、多核 CPU 的场景下。

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

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

乐观锁与悲观锁

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

原子操作类

IUC (java.util.concurrent) 中提供了原子操作类, 可以提供线程安全的操作, 例如: AtomicIntegerAtomicBoolean 等, 它们底层就是采用 CAS 技术 + volatile 来实现的。

可以使用 AtomicInteger 改写之前的例子: 一个线程自增、一个线程自减, 通过 原子类(cas+volatile)实现原子操作

// java 内存模型 —— CAS 原子类
public class Test {
    // 创建原子整数对象
    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
            }
        }, "t1");
 
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndDecrement(); // 获取并且自减 i--
            }
        }, "t2");
 
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        System.out.println(i);
    }
}

5. synchronized 优化

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tRvHtJv9-1655547709166)(C:\Users\86133\Pictures\JVM\内存模型\synchronized.png)]

轻量级锁

如果一个对象虽然有多线程访问, 但多线程访问的时间是错开的 (也就是没有竞争) , 那么可以使用轻量级锁来优化, 这就好比:

  • 学生 (线程A) 用课本占座, 上了半节课, 出门了 (CPU 时间到) , 回来一看, 发现课本没变, 说明没有竞争, 继续上他的课。
  • 如果这期间有其它学生 (线程B) 来了, 会告知 (线程A) 有并发访问, 线程A 随即升级为重量级锁, 进入重量级锁的流程。
  • 而重量级锁就不是那么课本占座那么简单了, 可以想象线程A 走之前, 把座位用一个铁栅栏围起来。

假设有两个方法同步块, 利用同一个对象加锁:

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

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

线程 1对象 MarkWord线程 2
访问同步块 A, 把 Mark 复制到线程1 的锁记录01 (无锁)
CAS 修改 Mark 为线程1 锁记录地址01 (无锁)
成功 (加锁)00 (轻量锁) 线程 1 锁记录地址
执行同步块 A00 (轻量锁) 线程 1 锁记录地址
访问同步块 B, 把 Mark 复制到线程1 的锁记录00 (轻量锁) 线程 1 锁记录地址
CAS 修改 Mark 为线程 1 锁记录地址00 (轻量锁) 线程 1 锁记录地址
失败 (发现是自己的锁)00 (轻量锁) 线程 1 锁记录地址
锁重入00 (轻量锁) 线程 1 锁记录地址
执行同步块 B00 (轻量锁) 线程 1 锁记录地址
同步块 B 执行完毕00 (轻量锁) 线程 1 锁记录地址
同步块 A 执行完毕00 (轻量锁) 线程 1 锁记录地址
成功 (解锁)01 (无锁)
01 (无锁)访问同步块 A, 把 Mark 复制到线程 2 的锁记录
01 (无锁)CAS 修改 Mark 为线程 2 锁记录地址
00 (轻量锁) 线程 2 锁记录地址成功 (加锁)

锁膨胀

如果在尝试轻量级锁的过程中, CAS 操作无法成功, 这时一种情况就是有其它线程为此对象加上轻量级锁 (有竞争), 这时需要进行锁膨胀, 将轻量级变为重量级锁。

static Object obj = new Object();
public static void method1() {
    synchronized(obj) {
        // 同步块
    }
}
线程 1对象 MarkWord线程 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 (重量锁) 重量锁指针阻塞中
释入重量锁, 唤起阻塞线程竞争10 (重量锁)阻塞中
10 (重量锁)竞争重量锁
10 (重量锁)成功 (加锁)

重量锁

重量级锁竞争的时候, 还可以使用自旋来进行优化, 如果当前线程自旋成功 (即这时候持锁线程已经退出了同步块, 释放锁), 这时当前线程就可以避免阻塞。

在 Java 6 之后自旋锁是自适应的, 比如对象刚刚的一次自旋操作成功过, 那么认为这次自旋成功的可能性会高, 就多自旋几次;反之, 就少自旋甚至不自旋, 总之, 比较智能。

  • 自旋会占用 CPU 时间, 单核 CPU 自旋就是浪费, 多核 CPU 自旋才能发挥优势。
  • 好比等红灯时汽车是不是熄火, 不熄火相当于自旋 (等待时间短了划算), 熄火了相当于阻塞 (等待时间长了划算)
  • Java 7 之后不能控制是否开启自旋功能

自旋重试成功的情况:

线程 1 (cpu 1 上)对象 Mark线程 2 (cpu 2 上)
10 (重量锁)
访问同步块, 获取 monitor10 (重量锁) 重量锁指针
成功 (加锁)10 (重量锁) 重量锁指针
执行同步块10 (重量锁) 重量锁指针
执行同步块10 (重量锁) 重量锁指针访问同步块, 获取 monitor
执行同步块10 (重量锁) 重量锁指针自旋重试
执行完毕10 (重量锁) 重量锁指针自旋重试
成功 (解锁)10 (无锁)自旋重试
10 (重量锁) 重量锁指针成功 (加锁)
10 (重量锁) 重量锁指针执行同步块

自旋重试失败的情况:

线程 1 (cpu 1 上)对象 Mark线程 2 (cpu 2 上)
10 (重量锁)
访问同步块, 获取 monitor10 (重量锁) 重量锁指针
成功 (加锁)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 禁用偏向锁 (偏向锁是撤销是一个非常重的操作)

为什么频繁的偏向锁撤销会导致STW时间增加呢?阅读偏向锁源码可以知道: 偏向锁的撤销需要等待全局安全点 (safe point),暂停持有偏向锁的线程, 检查持有偏向锁的线程状态。首先遍历当前JVM的所有线程, 如果能找到偏向线程, 则说明偏向的线程还存活, 此时检查线程是否在执行同步代码块中的代码, 如果是, 则升级为轻量级锁, 进行CAS竞争锁。可以看出撤销偏向锁的时候会导致stop the word。

假设有两个方法同步块, 利用同一个对象加锁

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

最后以一张表格来总结一下 synchronized 锁的知识:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vn3MMhw2-1655547709167)(C:\Users\86133\Pictures\JVM\内存模型\synchronized锁.png)]

其他优化

减少上锁时间

同步代码块中尽量短

减少锁的粒度

将一个锁拆分为多个锁提高并发度, 例如:

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

多次循环进入同步块不如同步块内多次循环
另外 JVM 可能会做如下优化, 把多次 append 的加锁操作粗化为一次 (因为都是对同一个对象加锁, 没必要重入多次)

new StringBuffer().append("a").append("b").append("c");
锁消除

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

读写分离

CopyOnWriteArrayList

CopyOnWriteSet

我的个人主页: www.ayu.link
本文连接: ┏ (゜ω゜)=☞

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值