「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。
1.什么是锁
并发情况下,多个线程会对同一个资源进行争抢,那么就有可能导致数据在某一个时刻不一致的问题。为了解决这个问题,Java 引入了锁的机制,通过一种抽象的锁来对资源进行锁定。
2.Java 锁的设计思路
2.1 前置知识
如图所示是 JVM 运行时内存结构。
其中虚拟机栈、本地方法栈、程序计数器是私有的,因此线程安全。
然而方法区 和 堆是共有的:Java 堆,存放了Java对象;方法区存放类信息、常量、静态数据。当多个线程访问这两个区的数据库,可能会导致数据异常的情况。因此需要锁机制对其进行限制。
2.2 代码层面的设计
简单来说,Java 中每个对象都有一把锁,每把锁都存放在 Java 对象的对象头中,记录了当前对象被哪个锁占用。
我们首先看最后的2bit,他们分别表示无锁、偏向锁、轻量级锁、重量级锁。
Synchronized 探究
原理
synchronized 在java编译后,会出现 monitorenter和 monitorexit两条字节码指令 monitor通常被称为管程或者监视器
存在的问题
synchronized最大的问题就是性能问题:synchronzied编译后得到的是两条有关monitor的两条字节码指令,但是monitor是依赖于操作系统的mutex和lock来实现的。Java线程实际是对操作系统线程的映射。 因此,JAVA 线程进行 挂起 和唤醒的时候,会改变操作系统的内核态。切换时间有可能大于执行时间
Synchronized 的改进
Java 6 之后,引入偏向锁、轻量级锁、重量级锁。锁只能升级,不能降级 Synchronized 琐升级过程
无锁
- 资源没有竞争;
- 存在竞争,但是采用非锁方式,例如CAS。
偏向锁
大部分情况下,资源虽然并很多线程竞争,但是总是会有同一个线程多次获得。为了让线程获得相同锁的代价相同,就有了偏向锁的概念: 当线程获得到资源时,会在 对象头中存储当前线程的id,不主动释放锁。后续这个线程进入和退出该资源时,不需要再次加锁和释放锁,而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果指向了该线程id,那么就不用再次尝试加锁了。 当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释锁。
重量级锁
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。