Java JUC 笔记(1)

Java JUC 笔记(1)

锁机制

使用synchronized加锁时,一定是和某个对象关联的;

image-20230306170923799

当带锁的方法反编译成字节码后,我们会看到有一个monitor enter指令和monitor exit指令,他们就分别对应加锁和释放锁; 每个对象都有一个monitor监视器与之对应的是,而monitor enter正是要获取该对象监视器的所有权,一旦某个对象的监视器被获取,其他对象就无法得到(唯一)

仔细看字节码中,其实有两个monitor exit; 如果程序是正常退出,会直接从第一个exit跳转回return, 若不是正常退出,就会从exit2出去,执行10~14行的异常处理程序,通过athrow抛出异常

image-20240302110324708

重量级锁

在JDK6之前,synchronized被称为重量级锁,monitor依赖于底层操作系统的lock来实现,而虚拟机上的线程是映射到原生系统的线程上,切换成本高。

对于每个对象,都有一个monitor与之关联,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

每个等待锁的线程都会被封装成ObjectWaiter对象,进入到如下机制:

image-20230306171005840

ObjectWaiter先进入EntrySet等待,获取到对象的监视器后进入The Owner区,若线程调用wait方法,就释放监视器,并进入WaitSet等待唤醒。

在JDK1.4.2时引入了自旋锁(JDK6后默认开启),它会让处于等待状态的线程不断地循环尝试获取锁。这种锁在等待时间不长的情况下表现很好,但是等待时间过长,就会浪费处理器资源;因此,自旋锁的循环次数一般是有限的(JDK6以后自旋次数自适应变化),超过次数以后,就会采用重量级锁机制。

轻量级锁

轻量级锁的目标是在竞争的情况下减少重量级锁产生的性能消耗。

在开始执行同步代码块前,先检查对象的MarkWord,查看锁对象是否被占用,若未被占用,则会在当前线程的帧栈中开一个名为Lock Record的空间,用于复制并存储对象的Mark Word信息

接着虚拟机使用CAS操作将Mark Word更新为当前轻量级锁的状态(数据结构变为指向Lock Record的指针,指向的是当前的栈帧)。

CAS在之前的Redis秒杀的笔记中有体积到

Redis - 秒杀笔记 1

根据CAS操作的结果来判定是否有线程占用当前同步代码块。若发现有线程在占用,则只好将锁膨胀为重量级锁,按照重量级锁来进行操作(锁的膨胀不可逆)

image-20230306171031953

更新锁记录的过程就是将线程的本地的栈帧记录和对象的MarkWord进行比较,若不存在或指向自身,说明可以直接获取锁;若不同,说明有其他线程在占用锁,执行膨胀操作。解锁过程仍然执行CAS,若对象的MarkWord还指向自身帧栈记录,则将他们交换。若指向其他线程帧栈,说明有其他线程也曾尝试获取过锁,这需要执行唤醒操作。

偏向锁

偏向锁相比于轻量级锁,消除了CAS操作;其专门为单个线程服务,当某个线程第一次获取锁,若后续都没有其他线程来获取该锁,那么持有该所的线程不需要进行同步操作。

若有其他锁来请求获取锁,那么偏向锁就会根据当前状态,决定是否要将当前锁恢复到未锁定或膨胀为轻量级锁。

可以添加-XX:+UseBiased参数来开启偏向锁

值得注意的是,如果对象通过调用hashCode()方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用hashCode()方法,那么会直接将锁升级为重量级锁,并将哈希值存放在monitor(有预留位置保存)中。

综上,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁

锁消除和锁粗化

锁消除和锁粗化都是在运行时的一些优化方案,比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除。锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁,这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展。


JVM内存模型

Java内存模型

image-20230306171115671

JVM的内存模型规定如下

  • 所有变量全都存储在主内存(这里包括下面提到的内存都指的是可能会出现竞争的变量,不包括局部变量)

  • 每个线程都有自己的工作内存,线程对变量的所有操作,都需要通过工作内存,然后通过工作内存和主内存之间的save和load操作实现操作

  • 不同线程之间的工作内存互相隔离,线程之间的内容传递必须经过主内存完成

之前的JVM笔记中提到一个案例

开两个线程对变量i进行自增操作,每个线程自增一万次
最后执行的结果却不是2万
这是因为这两个线程在操作工作内存与主内存的交互间出现了穿插,造成了数据的覆写,所以就会产生错误的结果

在之前我们就可以通过synchronized关键字添加同步代码块轻松解决,在后面会讲解通过原子类方法解决这类问题的方案

重排序

在程序编译或执行时,为了优化程序执行效率,编译器或处理器通常会对指令进行重排序

  • 编译器重排序:Java编译器通过对Java代码的解析,根据优化规则对代码进行重排序
  • 机器指令级的重排序:现代处理器能自主判断和变更机器指令的执行顺序

虽然但是,多线程环境下的指令重排序可能会导致一些问题。

volatile关键字

先介绍三个词

  • 原子性:要么全执行,要么全不执行
  • 可见性:当多个线程访问同一个变量,一旦一个线程修改了改变了,其他线程立马就能看见修改后的值
  • 有序性:程序按照代码先后顺序执行

volatile关键字可以使其修饰的变量具有可见性,但是其无法保证原子性

比如

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;   //很明显,按照我们的逻辑来说,a的值被修改那么另一个线程将不再循环
    }
}

就这样直接执行肯定会让while无限循环,如果我们使用synchronized加一把锁,显然是可以解决问题的,但是这样造成了大量的开销,这是就可以引入volatile关键字了

private static volatile int a = 0;

由于volatile关键字无法保证原子性;对这个案例做分析,一个简单的自增操作是由很多个指令组合而成的(写入,修改,写回),当线程a完成写回操作后,线程b正在执行写回操作,这是已经无法刹住车了,就算知道了写回变量的最新的值了,也无法进行修正了

这种情况会使用后面的原子类来解决这类问题

volatile关键字会禁止指令重排,这样就会保证多线程状态下不出岔子;

对于volatile关键字修饰的变量,在编译时会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

内存屏障作用

  • 保证特定操作顺序
  • 保证某些变量的内存可见性
屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证Load1的读取操作在Load2及后续读取操作之前执行
StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前,保证Store1的写操作已刷新到主内存
LoadStoreLoad1;LoadStore;Store2在Store2及其后的写操作执行前,保证Load1的读操作已读取结束
StoreLoadStore1;StoreLoad;Load2保证load1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

happens-before原则

JMM提出了happens-before(先行发生)原则,定义一些禁止编译优化的场景,来做一些保证,只要我们是按照原则进行编程,那么就能够保持并发编程的正确性。具体如下:

  • 程序次序规则: 同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。
    • 同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求。
  • 监视器锁规则: 对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
    • 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量x的值修改为了12并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到x是前一个线程修改后的结果12(所以synchronized是有happens-before规则的)
  • volatile变量规则: 对一个volatile变量的写操作happens-before后续对这个变量的读操作。
    • 就是如果一个线程先去写一个volatile变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见。
  • 线程启动规则: 主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。
    • 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  • 线程加入规则: 如果线程A执行操作join()线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。
  • 传递性规则: 如果A happens-before B,B happens-before C,那么A happens-before C。

本文参考白马讲师写的文档按照自己的理解进行部分修改,推荐大家去B站加入讲师的知识之海~

参考视频:Java JUC 并发编程 已完结(IDEA 2021版本)4K蓝光画质 玩转多线程

参考视频教程文档:柏码-JUC笔记(一)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值