并发编程-synchronized解决原子性问题

并发编程-synchronized解决原子性问题

文章目录

零、说在前面

有必要为友友们推荐一款极简的原生态AI:阿水AI6,需不需要都点点看看:👇👇👇
https://ai.ashuiai.com/auth/register?inviteCode=XT16BKSO3S
先看看美景养养眼,再继续以下乏味的学习,内容有点多,建议收藏分多次食用。
在这里插入图片描述

一、线程安全问题

1.1 什么是线程安全问题

当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也不论这些线程如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。

1.2 自增运算不是线程安全的

使用10个线程并行运行,对一个共享数据进行自增运算,每个线程自增运算1000次,具体代码如下:

public class PlusTest {
    final int MAX_TREAD = 10;
    final int MAX_TURN = 1000;
    CountDownLatch latch = new CountDownLatch(MAX_TREAD);

    /**
     * 测试用例:测试不安全的累加器
     */
    @org.junit.Test
    public void testNotSafePlus() throws InterruptedException {
        NotSafePlus counter = new NotSafePlus();
        Runnable runnable = () ->
        {
            for (int i = 0; i < MAX_TURN; i++) {
                counter.selfPlus();
            }
            latch.countDown();
        };
        for (int i = 0; i < MAX_TREAD; i++) {
            new Thread(runnable).start();
        }
        latch.await();
        Print.tcfo("理论结果:" + MAX_TURN * MAX_TREAD);
        Print.tcfo("实际结果:" + counter.getAmount());
        Print.tcfo("差距是:" + (MAX_TURN * MAX_TREAD - counter.getAmount()));
    }

}



public class NotSafePlus {

    private Integer amount = 0;

    //自增
    public void selfPlus() {
        amount++;
    }

    public Integer getAmount() {
        return amount;
    }

}

运行结果

[main|PlusTest.testNotSafePlus]:理论结果:10000
[main|PlusTest.testNotSafePlus]:实际结果:3557
[main|PlusTest.testNotSafePlus]:差距是:6443

结果分析:

为什么自增运算不是线程安全的呢?实际上,一个自增运算符是一个复合操作,至少包括三个JVM指令:“内存取值”“寄存器增加1”“存值到内存”。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。

比如在amount=100时,假设有三个线程同一时间读取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入到amount的内存,amount的结果是101,而不是103。

“内存取值”“寄存器增加1”“存值到内存”这三个JVM指令是不可以再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性。比如先读后写,就有可能在读之后,其实这个变量被修改了,就出现了数据不一致的情况。

1.3 临界区资源与临界区代码段

临界区资源表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。

在并发情况下,临界区资源是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后进行临界区代码段,执行完成之后释放资源。

image-20240910151759194

二、synchronized 关键字的使用

2.1 synchronized 关键字作用

在Java中,线程同步使用最多的方法是使用synchronized关键字。每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。

解决原子性问题

2.2 synchronized 内置锁如何使用

1、修饰方法(同步方法)

synchronized关键字是Java的保留字,当使用synchronized关键字修饰一个方法的时候,该方法被声明为同步方法。在方法声明中设置synchronized同步关键字,保证了其方法的代码执行流程是排他性的。任何时间只允许一条线程进入同步方法(临界区代码段),如果其他线程都需要执行同一个方法,那么只能等待和排队。

//同步方法
public synchronized void selfPlus()
{
	amount++;
}
2、修饰代码快(同步代码快)

如果方法中内容太多,还继续使用同步方法,则会影响执行效率。为了执行效率,最好将同步方法分为小的临界区代码段。

将synchronized加在方法上,如果其保护的临界区代码段包含的临界区资源(要求是相互独立的)多于一个,会造成临界区资源的闲置等待,这就会影响临界区代码段的吞吐量。为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。synchronized同步块的写法是:

synchronized(syncObject) //同步块而不是方法
{
	//临界区代码段的代码块
}

在synchronized同步块后边的括号中是一个syncObject对象,代表着进入临界区代码段需要获取syncObject对象的监视锁,或者说将syncObject对象监视锁作为临界区代码段的同步锁。由于每一个Java对象都有一把监视锁(Monitor),因此任何Java对象都能作为synchronized的同步锁。单个线程在synchronized同步块后边同步锁后,方能进入临界区代码段;反过来说,当一条线程获得syncObject对象的监视锁后,其他线程就只能等待。

3、同步方法和同步代码块的区别与联系

区别:

synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多条线程并发访问的。在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。所以synchronized代码块比synchronized方法更加细粒度地控制了多条线程的同步访问。

联系:

在Java的内部实现上,synchronized方法实际上等同于用一个synchronized代码块,这个代码块包含了同步方法中的所有语句,然后在synchronized代码块的括号中传入this关键字,使用this对象锁作为进入临界区的同步锁。

synchronized方法的同步锁实质上使用了this对象锁,这样就免去了手工设置同步锁的工作。而使用synchronized代码块需要手工设置同步锁。

2.3 synchronized 内置锁分类

image-20240910171820685

1、对象锁-代码块形式:手动指定锁定对象,也可是是this,也可以是自定义的锁
//示例1
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
        synchronized (this) {
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

// 示例2
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
        // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
        synchronized (block1) {
            System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
        }

        synchronized (block2) {
            System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}
2、对象锁-方法锁形式:synchronized修饰普通方法,锁对象默认为this
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    public synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}
3、类锁-synchronize修饰静态方法
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    // synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
    public static synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}
4、类锁-synchronized指定锁对象为Class对象
public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 所有线程需要的锁都是同一把
        synchronized(SynchronizedObjectLock.class){
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

2.4 synchronized 内置锁释放

使用synchronized块时不必担心监视锁的释放问题,同步代码块正确执行成功之后,监视锁会自动释放;如果程序出现异常,监视锁也会自动释放。

image-20240910173248770

2.5 ynchronized 使用不当带来的死锁

synchronized同步锁虽然能够解决线程安全问题,但是如果使用不当,就会导致死锁,即请求被阻塞一直无法返回。

死锁线程:

两个或者两个以上的线程在执行过程中,由于争夺同一个共享资源造成的相互等待的现象,在没有外部干预的情况下,这些线程将会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。

image-20240910214741428

1、死锁案例

定义一个资源类,提供如下两个方法,这两个方法都加了synchronized对象锁。

  • saveResource()方法,用于保存资源。
  • statisticsResource()方法,用于统计资源数量。

image-20240910215039497

演示死锁代码如下:

image-20240910215119031

两个线程分别访问两个不同的Resource对象,每个resource对象分别调用saveResource()方法保存resource对象的资源,这必然会导致死锁问题。由于两个线程持有自己的对象锁资源,在saveResource()方法中访问对方的statisticsResource()方法并占用对方的锁资源,所以产生互相等待造成死锁的现象。

2、死锁产生的必要条件

不管是线程级别的死锁,还是数据库级别的死锁,只能通过人工干预去解决,所以我们要在写程序的时候提前预防死锁的问题。导致死锁的条件有四个,这四个条件同时满足就会产生死锁。

image-20240910215704148

3、如何解决死锁问题

按照前面说的四个死锁的发生条件,我们只需要破坏其中任意一个,就可以避免死锁的产生。其中,互斥条件我们不可以破坏,因为这是互斥锁的基本约束,其他三个条件都可以破坏。

image-20240910220016595

4、

三、Java 对象结构与内置锁

3.1 Java对象结构

一个Java对象在JVM中的存储结构如下:

image-20240909150942508

3.2 Mark Word 的结构信息

Java对象对象头中的Mark Word存储内容如下:

image-20240909151314585

3.3 无锁、偏向锁、轻量级锁和重量级锁

synchronized的不同锁类型如下:

image-20240909151854885

在Java对象的Mark Word中不同的信息表示不同的锁,具体信息如下:

image-20240909151709587

由biased 和lock位的不同值,分别表示无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。

image-20240909152111200

四、偏向锁的原理与实战

4.1 偏向锁的核心原理

为什么要引入偏向锁?偏向锁的核心原理和核心思想是什么?引入偏向锁有什么缺点?

image-20240909152417721

Java偏向锁是Java6引入的一项多线程优化。顾名思义,它会偏向于第一个访问锁对象的线程,如果同步锁只有一个线程访问,则线程是不需要触发同步的,这种情况下,就会给该线程加一个偏向锁;如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁升级到轻量级锁,然后再唤醒原持有偏向锁的线程。

image-20240910174836024

4.2 偏向锁的演示案例

偏向锁的案例代码如下:

public class InnerLockTest {
    final int MAX_TREAD = 10;
    final int MAX_TURN = 1000;
    CountDownLatch latch = new CountDownLatch(MAX_TREAD);

    //偏向锁测试案例
    @org.junit.Test
    public void showBiasedLock() throws InterruptedException {
        Print.tcfo(VM.current().details());
        //JVM延迟偏向锁
        sleepMilliSeconds(5000);

        ObjectLock lock = new ObjectLock();

        Print.tcfo("抢占锁前, lock 的状态: ");
        lock.printObjectStruct();

        sleepMilliSeconds(5000);
        CountDownLatch latch = new CountDownLatch(1);
        Runnable runnable = () ->
        {
            for (int i = 0; i < MAX_TURN; i++) {
                synchronized (lock) {
                    lock.increase();
                    if (i == MAX_TURN / 2) {
                        Print.tcfo("占有锁, lock 的状态: ");
                        lock.printObjectStruct();
                        //读取字符串型输入,阻塞线程
//                        Print.consoleInput();
                    }
                }
                //每一次循环等待10ms
                sleepMilliSeconds(10);
            }
            latch.countDown();
        };
        new Thread(runnable, "biased-demo-thread").start();
        //等待加锁线程执行完成
        latch.await();
        Print.tcfo("释放锁后, lock 的状态: ");
        lock.printObjectStruct();
    }

}

4.3 偏向锁获取锁流程

偏向锁的获得锁的逻辑如下:

image-20240909153323450

4.4 偏向锁的撤销和膨胀

假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁。

1、偏向锁的撤销

image-20240910181205732

为什么调用Object.hashCode()或者System.identityHashCode()方法计算对象的哈希码之后,偏向锁将撤销?

偏向锁状态下Mark Word内容如下:

image-20240910181831244

因为偏向锁没有存储Mark Word备份信息的地方。换句话说,因为对于一个对象其哈希码只会生成一次并保存在Mark Word中,偏向锁对象的Mark Word已经保存了线程ID,没有地方再保存哈希码时,所以只能撤销偏向锁,将Mark Word用于存放对象的哈希码。

轻量级锁会在帧栈的Lock Record(锁记录)中记录哈希码,重量级锁会在监视器中记录哈希码,起到了对哈希码备份的作用。而偏向锁没有地方备份哈希码,所以只能撤销偏向锁。调用哈希码计算将会使对象再也无法偏向,因为在Mark Word中已经放置了哈希码,偏向锁没有办法放置Thread ID了。调用哈希码计算后,当锁对象可偏向时,Mark Word将变成未锁定状态,并只能升级成轻量级锁;当对象正处于偏向锁时,调用哈希码将使偏向锁撤销后强制升级成重量锁。

image-20240910182046436

偏向锁撤销的过程大致如下

image-20240910182853684

2、偏向锁的膨胀

如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。

如果JVM检查到原来的线程依然存活,就表明原来的线程还在使用偏执锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。

五、轻量级锁的原理与实战

5.1 轻量级锁的核心原理

为什么引入轻量级锁?轻量级锁的核心原理是什么?

image-20240909165326635

轻量级锁的加锁过程

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“ 01 ”状态,是否为偏向锁为“ 0 ”),虚拟机首先将在 当前线程的栈帧中 建立一个名为锁记录( Lock Record )的空间,用于存储锁对象目前的markword的拷贝,官方称之为Displaced Mark Word 。

    image-20240910221523224

  2. 拷贝对象头中的 markword 复制到锁记录中;

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的 markword 更新为指向LockRecord的指针,并将Lock Record里的 owner指针 指向对象的mark word。如果更新成功,则执行 步骤④ ,否则执行 步骤⑤ 。

  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,将对象markword的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示:

    image-20240910221543180

  5. 如果这个更新操作失败了,则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”, markword 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

抢锁线程在通过CAS自旋更新完Mark Word之后,还会做两个善后工作:

  1. 将含有锁对象信息(如哈希表等)的旧Mard Word值保存在抢锁线程Lock Record的DisplacedMark Word(可以理解为放错地方的Mark Word)字段中,这一步起到备份的作用,以便锁释放之后,将旧的Mark Word值恢复到锁对象头部。
  2. 抢锁线程将栈帧中的锁记录的owner指针指向锁对象。

image-20240909165541502

锁记录是线程私有的,每个线程有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word拷贝到锁记录的Displaced Mark Word字段。这是为什么呢?因为内置锁对象的MarkWord的结构会有所变化,Mark Word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用。

5.2 轻量级锁的案例演示

轻量级锁演示代码如下

public class InnerLockTest {
    final int MAX_TREAD = 10;
    final int MAX_TURN = 1000;
    CountDownLatch latch = new CountDownLatch(MAX_TREAD);

    @org.junit.Test
    public void showLightweightLock() throws InterruptedException {

        Print.tcfo(VM.current().details());
        //JVM延迟偏向锁
        sleepMilliSeconds(5000);

        ObjectLock lock = new ObjectLock();

        Print.tcfo("抢占锁前, lock 的状态: ");
        lock.printObjectStruct();

        sleepMilliSeconds(5000);
        CountDownLatch latch = new CountDownLatch(2);
        Runnable runnable = () ->
        {
            for (int i = 0; i < MAX_TURN; i++) {
                synchronized (lock) {
                    lock.increase();
                    if (i == 1) {
                        Print.tcfo("第一个线程占有锁, lock 的状态: ");
                        lock.printObjectStruct();
                    }
                }

            }
            //循环完毕
            latch.countDown();

            //线程虽然释放锁,但是一直存在
            for (int j = 0; ; j++) {
                //每一次循环等待1ms
                sleepMilliSeconds(1);
            }
        };
        new Thread(runnable).start();


        sleepMilliSeconds(1000); //等待1s

        Runnable lightweightRunnable = () ->
        {
            for (int i = 0; i < MAX_TURN; i++) {
                synchronized (lock) {
                    lock.increase();
                    if (i == MAX_TURN / 2) {
                        Print.tcfo("第二个线程占有锁, lock 的状态: ");
                        lock.printObjectStruct();
                    }
                    //每一次循环等待1ms
                    sleepMilliSeconds(1);
                }
            }
            //循环完毕
            latch.countDown();
        };
        new Thread(lightweightRunnable).start();
        //等待加锁线程执行完成
        latch.await();
        sleepMilliSeconds(2000);  //等待2s
        Print.tcfo("释放锁后, lock 的状态: ");
        lock.printObjectStruct();
    }
}


5.3 轻量级锁获取锁流程

轻量级锁获取锁流程如下

image-20240909160550549

轻量级锁的加锁原理如下

image-20240910221918166

5.4 轻量级锁的释放流程

偏向锁也有锁释放的逻辑,但是它只是释放Lock Record,原本的偏向关系仍然存在,所以并不是真正意义上的锁释放。而轻量级锁释放之后,其他线程可以继续使用轻量级锁来抢占锁资源,具体的实现流程如下。

第一步,把Lock Record中_displaced_header存储的lock锁对象的Mark Word替换到lock锁对象的Mark Word中,这个过程会采用CAS来完成。

第二步,如果CAS成功,则轻量级锁释放完成。

第三步,如果CAS失败,说明释放锁的时候发生了竞争,就会触发锁膨胀,完成锁膨胀之后,再调用重量级锁的释放锁方法,完成锁的释放过程。

5.5 轻量级锁和偏向锁的对比

偏向锁,就是在一段时间内只由同一个线程来获得和释放锁,加锁的方式是把Thread Id保存到锁对象的Mark Word中。

轻量级锁,存在锁交替竞争的场景,在同一时刻不会有多个线程同时获得锁,它的实现方式是在每个线程的栈帧中分配一个BasicObjectLock对象(Lock Record),然后把锁对象中的Mark Word拷贝到Lock Record中,最后把锁对象的Mark Word的指针指向Lock Record。轻量级锁之所以这样设计,是因为锁对象在竞争的过程中有可能会发生变化,但是每个线程的Lock Record的Mark Word不会受到影响。因此当触发锁膨胀时,能够通过Lock Record和锁对象的Mark Word进行比较来判定在持有轻量级锁的过程中,锁对象是否被其他线程抢占过,如果有,则需要在轻量级锁释放锁的过程中唤醒被阻塞的其他线程。

六、重量级锁的原理与实战

6.1 重量级锁的核心原理

1、监视器原理

重量级锁原理

image-20240909171107113

监视器特点

image-20240909171235059

2、监视器ObjectMonitor

ObjectMonitor组件介绍

image-20240909173823423

在 Hotspot虚拟 机中,监 视器是由 C++类 ObjectMonitor实现 的,ObjectMonitor类定 义在ObjectMonitor.hpp文件中,其构造器代码大致如下:

ObjectMonitor::ObjectMonitor() {
    _header = NULL;
    _count = 0;
    _waiters = 0,
    //线程的重入次数
    _recursions = 0;
    _object = NULL;
    //标识拥有该monitor的线程
    _owner = NULL;
    //等待线程组成的双向循环链表
    _WaitSet = NULL;
    _WaitSetLock = 0 ;
    _Responsible = NULL ;
    _succ = NULL ;
    //多线程竞争锁进入时的单向链表
    cxq = NULL ;
    FreeNext = NULL ;
    //_owner从该双向循环链表中唤醒线程节点
    _EntryList = NULL ;
    _SpinFreq = 0 ;
    _SpinClock = 0 ;
    OwnerIsThread = 0 ;
}
3、ObjectMonitor 的内部抢锁过程

内部抢锁流程图如下:

image-20240909175618348

步骤说明:

  1. JVM每次从队列的尾部取出一个数据用于锁竞争候选者( OnDeck ),但是并发情况下, ContentionList(contention:争论,争夺)会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到 EntryList (entry:进入)中作为候选竞争线程。
  2. Owner线程会在unlock时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList中的某个线程为 OnDeck线程 (一般是 最先进去 的那个线程)。
  3. Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“ 竞争切换 ”。
  4. OnDeck线程获取到锁资源后会变为 Owner线程 ,而没有得到锁资源的仍然停留在 EntryList 中。如果Owner线程被wait()方法阻塞,则转移到 Waiting Queue 中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去 EntryList 中。 处于 ContentionList 、 EntryList 、 WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
  5. Synchronized是非公平锁。 Synchronized在线程进入 ContentionList 前,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能 直接抢占 OnDeck线程的锁资源。
4、重量级锁的实现流程

image-20240910222724043

6.2 内核态和用户态

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。

Linux系统的体系架构分为用户态(或者用户空间)和内核态(或者内核空间)。

image-20240909181645621

1、内核态

Linux系统的内核是一组特殊的软件程序,负责控制计 图 2-15 Linux 进程的用户态与内核态算机的硬件资源,例如协调CPU资源、分配内存资源,并且提供稳定的环境供应用程序运行。

2、用户态

应用程序的活动空间为用户空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。

用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用

3、用户态内核态切换

用户态与内核态有各自专用的内存空间、专用的寄存器等,进程从用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态,也可以运行在内核态,那么它们之间肯定存在用户态和内核态切换的过程。进程从用户态到内核态切换主要包括以下三种方式:

  1. 硬件中断。硬件中断也称为外设中断,当外设完成用户请求时,会向CPU发送中断信号。
  2. 系统调用。其实系统调用本身就是中断,只不过是软件中断,与硬件中断不同。
  3. 异常。如果当前进程运行在用户态,这时发生了异常事件(例如缺页异常),就会触发切换。
4、pthread_mutex_lock系统调用

pthread_mutex_lock系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁(Mutex),这是重量级锁开销很大的原因。

6.3 重量级锁的演示案例

重量级锁演示代码如下:

package com.crazymakercircle.innerlock;

import com.crazymakercircle.util.Print;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;

import java.util.concurrent.CountDownLatch;

import static com.crazymakercircle.util.ThreadUtil.sleepMilliSeconds;

/**
 * Created by 尼恩@疯狂创客圈.
 */
public class InnerLockTest {
    final int MAX_TREAD = 10;
    final int MAX_TURN = 1000;
    CountDownLatch latch = new CountDownLatch(MAX_TREAD);
    
    
    @org.junit.Test
    public void showHeavyweightLock() throws InterruptedException {

        Print.tcfo(VM.current().details());
        //JVM延迟偏向锁
        sleepMilliSeconds(5000);

        ObjectLock counter = new ObjectLock();

        Print.tcfo("抢占锁前, counter 的状态: ");
        counter.printObjectStruct();

        sleepMilliSeconds(5000);
        CountDownLatch latch = new CountDownLatch(3);
        Runnable runnable = () ->
        {
            for (int i = 0; i < MAX_TURN; i++) {
                synchronized (counter) {
                    counter.increase();
                    if (i == 0) {
                        Print.tcfo("第一个线程占有锁, counter 的状态: ");
                        counter.printObjectStruct();
                    }
                }

            }
            //循环完毕
            latch.countDown();

            //线程虽然释放锁,但是一直存在
            for (int j = 0; ; j++) {
                //每一次循环等待1ms
                sleepMilliSeconds(1);
            }
        };
        new Thread(runnable).start();


        sleepMilliSeconds(1000); //等待2s

        Runnable lightweightRunnable = () ->
        {
            for (int i = 0; i < MAX_TURN; i++) {
                synchronized (counter) {
                    counter.increase();
                    if (i == 0) {
                        Print.tcfo("占有锁, counter 的状态: ");
                        counter.printObjectStruct();
                    }
                    //每一次循环等待10ms
                    sleepMilliSeconds(1);
                }
            }
            //循环完毕
            latch.countDown();
        };
        new Thread(lightweightRunnable, "抢锁线程1").start();
        sleepMilliSeconds(100);  //等待2s
        new Thread(lightweightRunnable, "抢锁线程2").start();

        //等待加锁线程执行完成
        latch.await();
        sleepMilliSeconds(2000);  //等待2s
        Print.tcfo("释放锁后, counter 的状态: ");
        counter.printObjectStruct();
    }

}


七、锁升级以及各种锁的对比

7.1 锁升级的实现流程

synchronized锁的升级流程如下:

image-20240909161722954

synchronized执行过程如下:

  1. 线程抢锁时,JVM首先检测内置锁对象Mark Word中biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
  2. 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
  3. 如果Mark Word中线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
  4. 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
  5. JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
  6. 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。

总体来说,偏向锁是在没有发生锁争用的情况下使用;一旦有了第二个线程的争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。

7.2 偏向锁、轻量级锁、重量级锁的对比

偏向锁、轻量级锁、重量级锁三种锁的对比如下:

image-20240909164003087

八、Synchronized与Lock对比

8.1 synchronized的缺陷

1、效率低

锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时

2、不够灵活

加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活

3、无法知道是否成功获得锁

相对而言,Lock可以拿到锁状态,而synchronized不能获取到锁状态。

8.2 Lock解决相应问题

详情请看:[并发编程-AbstractQueuedSynchronizer (AQS) 核心原理及应用](#并发编程-AbstractQueuedSynchronizer (AQS) 核心原理及应用)

参考
https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
《极致经典(卷2):Java高并发核心编程(卷2 加强版)》 作者:尼恩

  • 20
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Luo_xguan

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值