JVM学习笔记05:内存模型

文章目录

前言

本篇博客是跟随黑马程序员JVM完整教程,全网超高评价,全程干货不拖沓的学习JVM的笔记,若文章中出现相关问题,请指出!

所有博客文件目录索引:博客目录索引(持续更新)

一、Java内存模型

【java 内存结构】

【java 内存模型】:Java Memory Model(JMM)的意思,可参照Oracle官方说明

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



Synchronized原理分析(对象头解释)

Sychronized原理分析:使用Synchronized关键字包裹的代码块前后会执行monitorenter、monitorexit两个执行,分别是上锁、解锁的意思。

下面来通过三个线程使用同一把锁的例子来说明:对象头指的是每个对象实例或者说类对象实例中都包含Monitor对象头,在该对象头中包含一些相关信息,owner只能有一个,EntryList可以有多个,waitSet则是与wait()、notify()相关

image-20211212120132523

①来了一个线程1此时会去查看该对象头的Owner是否为null,若是null直接会加入进去,加入好之后就表示当前锁已上锁

image-20211212120403490

②接着又来了线程2、线程3,由于Owner已经有线程占用了,所以会将这两个线程添加至EntryList中,在其中的线程会进入阻塞等待状态

image-20211212120537214

③线程一执行完毕执行monitorexit指令后,该对象头的Owner头为null,此时会通知EntryList中的多个线程来进行抢占锁,此时Thread2抢占到了之后填充至Owner里

image-20211212120730473

大致流程都是如此,主要都是围绕着monitorenter、monitorexit两个指令来进行锁资源的占用与释放。



1.1、原子性

1.1.1、问题描述及分析

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

public class Main {

    static int i = 0;
    static Object obj = new Object();

    public static void main(String[] args) throws Exception{
        //对于两个线程中对公共变量进行操作不上锁
        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);
    }

}

image-20211212122445984

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

//一旦在多线程下,若是线程1的取出0加1结束为1,CPU时间片用完,此时线程2执行取出0又进行减法操作为-1,并重新赋值到变量i=-1,此时线程1又拿到时间片此时进行赋值操作i=1,可以看到命名进行一次加一、减一操作最终结果还是1,这也就是为什么上面最终没有得到结果为0的原因!
//i++
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i

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

java及操作系统 在多线程情况会交错执行,多线程模型都是一种抢先式多任务系统,线程会轮流拿到CPU的使用权,CPU会以时间片为单位,多个线程轮流使用CPU



1.1.2、解决方案

Synchronized进行操作:

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

优化处理1:

for (int j = 0; j < 5000; j++) {
    //对自增操作进行i++
    synchronized (obj) {
        i++;
    }
}

优化处理2:对于整个for循环进行sychronized同步操作,若是仅仅对循环中的i++进行同步方法,那么一次for循环下来就要执行5000次的monitorenter、monitorexit指令,也会有不小的开销

//优化处理:直接包裹整个for循环,这样的话也只会有一组monitor的时间开销
synchronized (obj) {
    for (int j = 0; j < 5000; j++) {
        i++;
    }
}

优化处理:减少加锁、解锁的次数



1.2、可见性

1.2.1、问题描述:退不出的循环

程序说明:开辟一个线程并以一个公共布尔变量作为循环条件,主线程睡眠1秒后对该布尔值修改成false,来看一下线程还会不断的在死循环中吗?

public class Main {

    static boolean flag = true;

    public static void main(String[] args) throws Exception{
        new Thread(()->{
            while (flag){

            }
        }).start();

        Thread.sleep(1000);
        Main.flag = false;//main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
    }

}

效果:可以看到程序一直处在死循环里,主线程对flag变量修改好像并没有影响到线程中的布尔值。

image-20211212125536076



1.2.2、问题分析及解决

原因分析

为什么呢?分析一下:

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

image-20211212125859386

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

image-20211212125923042

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

image-20211212150023605


解决方案

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

volatile static boolean flag = true;

方式二:在线程1中添加打印语句

new Thread(()->{
    while (flag){
        System.out.print("");//方法源码中使用到了sychronzied,同步关键字可以防止当前的线程从高速缓存中获取值,其会破坏JIT的优化,对于sychronized可以保证原子性也可以保证可见性
    }
}).start();


1.2.3、可见性说明

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况:上例从字节码理解是这样的

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++ 一个 i-- ,只能保证看到最新值,不能解决指令交错:

// 假设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是属于重量级操作,性能相对更低。同步关键字可以防止当前的线程从高速缓存中获取值,其会破坏JIT的优化,对于sychronized可以保证原子性也可以保证可见性



1.3、有序性

1.3.1、诡异的结果

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 已经执行过了)

还有一种情况最终r1的结果为0,这就跟ready出现指令重排的情况有关了!

线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行num = 2

指令重排出现情况:对于上面actor2方法中的ready = true;可能会优先于num=2之前,在执行完ready=true之后时间片又给到actor1中,此时就会取得r.r1=0的情况了!



1.3.2、压测测试

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

Step1:创建一个maven项目,引入jcstress依赖

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <jcstress.version>0.5</jcstress.version>
    <javac.target>1.8</javac.target>
    <uberjar.name>jcstress</uberjar.name>
</properties>

<dependencies>
    <dependency>
        <groupId>org.openjdk.jcstress</groupId>
        <artifactId>jcstress-core</artifactId>
        <version>${jcstress.version}</version>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.1</version>
            <configuration>
                <compilerVersion>${javac.target}</compilerVersion>
                <source>${javac.target}</source>
                <target>${javac.target}</target>
            </configuration>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>2.2</version>
            <executions>
                <execution>
                    <id>main</id>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                    <configuration>
                        <finalName>${uberjar.name}</finalName>
                        <transformers>
                            <transformer
                                         implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>org.openjdk.jcstress.Main</mainClass>
                            </transformer>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                                <resource>META-INF/TestList</resource>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Step2:编写测试类

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

@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) {  //r对象中的r1则是我们上面@Outcome注解对应要关注的取值
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

}

之后我们对项目进行打jar包,在指定目录下执行:java -jar jcstress.jar

image-20211212161046283

说明程序中出现了指令重排!



1.3.3、解决方案

使用volatile来对ready变量进行修改,volatile关键字可以对某个属性禁止指令重排,此时则能够防止出现0的情况!

volatile boolean ready = false;


1.3.4、有序性的理解

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

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

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

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

也可以是:

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

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

public 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;
    }
}

以上的实现特点是:

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

但在多线程环境下,上面的代码是有问题的,INSTANCE = new Singleton()对应的字节码为:

0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field
INSTANCE:Lcn/itcast/jvm/t4/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 才会真正有效。

//在该案例中volatile实际是去解决有序性的问题,也就是禁止指令重排,对于可见性synchronized就已经解决了!
private volatile static Singleton INSTANCE = null;


1.4、happens-before

【多线程】Volatile和Happens-Before原则

happens-before:规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

1、sychronized:线程解锁 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();

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

volatile static int x;
new Thread(()->{
    x = 10;
},"t1").start();
new Thread(()->{
    System.out.println(x);
},"t2").start();

3、线程 start 前对变量的写,对该线程开始后对该变量的读可见

static int x;
x = 10; 	//在线程执行前写,之后线程里读可见

new Thread(()->{
    System.out.println(x);
},"t2").start();

4、线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或t1.join()等待它结束)

static int x;
Thread t1 = new Thread(()->{
    x = 10;  //对x进行写操作
},"t1");
t1.start();
t1.join();
//线程结束后进行读可见
System.out.println(x);

5、线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或t2.isInterrupted)

public class Singleton {
    
    private static int x;

    public static void main(String[] args) throws InterruptedException {

        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();//使当前线程由执行状态,变成为就绪状态,让出cpu时间,在下一个线程执行时候,此线程有可能被执行,也有可能没有被执行
        }
        System.out.println("main线程:" + x);

    }
}

6、对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。

7、具有传递性,如果hb(A, B) , hb(B, C),那么hb(A, C)。

注意:变量都是指成员变量或静态成员变量。



二、CAS与原子类

2.1、认识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,所以线程不会陷入阻塞,这是效率提升的因素之一。
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。

CAS 底层依赖于一个Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子。

关于线程阻塞与CAS的区别说明:线程阻塞就会涉及到线程的上下文切换,所谓的上下文切换就是要把当前线程的状态保存下来,然后令其进行休眠阻塞,等到唤醒就会进行恢复上下文状态,这是很耗时间的,而CAS的线程一直在跑则不会陷入阻塞所以也就没有线程的上下文切换,效率对于在低竞争的时候会比sychronized的高!



2.2、手写实现CAS乐观锁

(CAS)compareAndSwapInt

// 最底层是native方法,那就是C语言实现的,目前没看过C源码,这个坟值得一挖.
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

// 使用就个样子
sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
boolean flag = sun.misc.Unsafe.compareAndSwapInt(object, offset, expect, update);

这里共有4个参数 (1)object:就是将要修改的值的对象. (2)offect:对象在内存中偏移量为offset处的值,就是要修改的数据的值在内存中的偏移量, 结合object + offect找到要修改的值. (3)expect:期望内存中的值,就是拿这个值和 object + offect值进行比较;如果相同则修改,返回true,否则返回false,等下次修改. (4)update:如果上一步对比相等,则将这个值赋值给 object + offect值,然后返回true。

手写CAS乐观锁

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子。

程序说明:通过借助Unsafe类的cas操作来实现对整型数字的加一、减一操作,乐观锁实现,也就是原子类的底层源码实现!

import sun.misc.Unsafe;

import java.lang.reflect.Field;

/**
 * @ClassName TestCAS
 * @Author ChangLu
 * @Date 2021/12/12 17:18
 * @Description TODO
 */
public class TestCAS {
    public static void main(String[] args) throws InterruptedException {
        DataContainer dataContainer = new DataContainer();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                dataContainer.increase();
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                dataContainer.decrease();
            }
        });
        t2.start();

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

        System.out.println(dataContainer.data);

    }
}

//volatile + cas来实现乐观锁
class DataContainer{
    public 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) {
            //获取共享变量旧值,可以在这一行加入断点,修改data调试来加深理解
            oldValue = data;
            //cas尝试修改data为旧值 - 1,如果期间旧值被别的线程修改了,返回false
            if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - 1)){
                return;
            }
        }
    }

}

效果:能够解决并发时+1、-1的原子性问题!

image-20211212173142631



2.3、乐观锁与悲观锁

CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

本质:乐观锁会对新值旧值进行不断比较重试,节省了悲观锁阻塞等待的上下文切换时间;悲观锁一旦没有抢占到锁就会进入阻塞状态,此时就会有上下文切换的开销。

  • 若是竞争较为激烈实际上乐观锁的性能也并不会很好,因为在cpu分配得到时间片的这段时间里会一直进行重试,那么这一段时间就是浪费了,并且会一直占用CPU资源,多核才能发挥优势。相反若是采用悲观锁进入阻塞无非就是要对当前线程进行保存状态就又会执行其他线程的操作,一直到之后被等待唤醒。
  • 若是竞争不激烈,乐观锁就可以省去大量的上下文切换状态的保存与恢复,也能够节省一大部分的时间开销。


2.4、原子操作类

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

下面举出原子整型类的示例:

public class Main {
    
    //使用原子类
    static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        DataContainer dataContainer = new DataContainer();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                num.incrementAndGet();
            }
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                num.decrementAndGet();
            }
        });
        t2.start();

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

        System.out.println(num.get());

    }
}


三、Synchronized优化

从JDK6开始对Synchronized进行了一系列的优化,在某些场景下性能比CAS更好!

会根据当前多少个线程抢占资源来对锁进行升级优化

若是对象一旦被加锁之后,它的对象头信息就会被替换标记位(标记你加的是什么锁)

轻量级锁:CAS

重量级锁-自旋:阻塞更改为自旋重试操作,减少了阻塞以至于进行上下文切换,若是自旋成功就会进行抢占锁;若是多次尝试自旋失败就会进入阻塞状态

偏向锁:针对锁重入情况进行优化,会拿自己的线程id来作竞争比较,若是自己的则不用进行CAS。


3.1、轻量级锁

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

学生(线程 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
    }
}

每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 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锁记录地址 -
成功(解锁) 01(无锁) -
- 01(无锁) 访问同步块 A,把 Mark 复制到线程 2 的锁记录
- 00(轻量锁)线程 2锁记录地址 CAS 修改 Mark 为线程 2 锁记录地址
- 00(轻量锁)线程 2锁记录地址 成功(加锁)


3.2、锁膨胀

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

static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块
    }
}
线程 1 对象 Mark Word 线程 2
访问同步块,把 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(重量锁) 成功(加锁)
- ... ...


3.3、重量锁

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

在 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(重量锁)重量锁指针 阻塞
- ... ...


3.4、偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS:

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
  • 访问对象的 hashCode 也会撤销偏向锁
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID 撤销偏向和重偏向都是批量进行的,以类为单位
  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
  • 可以主动使用-XX:-UseBiasedLocking禁用偏向锁

可以参考这篇论文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

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

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

通过检查是否有线程ID来确定其的存在!



3.5、其他优化

1、减少上锁时间:同步代码块中尽量短

2、减少锁的粒度:将一个锁拆分为多个锁提高并发度,例如:

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

3、锁粗化:对于多个连续的可重入锁情况粗话为一次

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

new StringBuffer().append("a").append("b").append("c");

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

5、读写分离:CopyOnWriteArrayList、CopyOnWriteSet,在写时会复制一份数组并对这个数组进行操作,而读操作则会读取原始的数组内容,此时读写可以互不干扰!



整理者:长路 时间:2021.12.12

评论区请在客户端页面查看