Java锁原理

独占锁

synchronized 和 ReentrantLock
比较

  1. 锁的实现
    synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
  2. 性能
    新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
  3. 等待可中断
    当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
    ReentrantLock 可中断,而 synchronized 不行。
  4. 公平锁
    公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。非公平锁减少了入队和出队的操作性能相对于公平锁要高一些。
  5. 锁绑定多个条件
    一个 ReentrantLock 可以同时绑定多个 Condition 对象。

volatile

volatile关键字是Java虚拟机提供的的最轻量级的同步机制,它作为一个修饰符, 用来修饰变量。它保证变量对所有线程可见性,禁止指令重排,但是不保证原子性。

  • Java虚拟机规范试图定义一种Java内存模型,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台上都能达到一致的内存访问效果。
  • Java内存模型规定所有的变量都是存在主内存当中,每个线程都有自己的工作内存。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。
  • 线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作操作主内存。并且每个线程不能访问其他线程的工作内存。

我们先来看下java内存模型(jmm):
在这里插入图片描述

可见性

在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,会多出lock addl。Lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 这个写回内存的操作会使其他cpu里缓存了该内存地址的数据无效。
    如果声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的还是旧的,在执行操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

禁止指令重排

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为如下三种:
在这里插入图片描述
1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
当变量声明为volatile时,Java编译器在生成指令序列时,会插入内存屏障指令。通过内存屏障指令来禁止重排序。
JMM内存屏障插入策略如下:
在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障。
在每个volatile读操作后面插入一个LoadLoad,LoadStore屏障。

通过上面这些我们可以得出如下结论:编译器不会对volatile读与volatile读后面的任意内存操作重排序;编译器不会对volatile写与volatile写前面的任意内存操作重排序。

防止重排序使用案例:

public class SafeDoubleCheckedLocking {
    private volatile static Instance instane;
    public  static Instance getInstane(){
        if(instane==null){
            synchronized (SafeDoubleCheckedLocking.class){
                if(instane==null){
                    instane=new Instance();
                }
            }
        }
        return instane;
    }
}

创建一个对象主要分为如下三步:

  1. 分配对象的内存空间。
  2. 初始化对象。
  3. 设置instance指向内存空间。

如果instane 不加volatile,上面的2,3可能会发生重排序。假设A,B两个线程同时获取,A线程获取到了锁,发生了指令重排序,先设置了instance指向内存空间。这个时候B线程也来获取,instance不为空,这样B拿到了没有初始化完成的单例对象

Volatile与Synchronized比较

Volatile是轻量级的synchronized,因为它不会引起上下文的切换和调度,所以Volatile性能更好。
Volatile只能修饰变量,synchronized可以修饰方法,静态方法,代码块。
Volatile对任意单个变量的读/写具有原子性,但是类似于i++这种复合操作不具有原子性。而锁的互斥执行的特性可以确保对整个临界区代码执行具有原子性。
多线程访问volatile不会发生阻塞,而synchronized会发生阻塞。
volatile是变量在多线程之间的可见性,synchronize是多线程之间访问资源的同步性。

synchronized原理

基于JVM中Java对象头,以及Monitor对象监视器。

Java对象内存分布

通过JOL工具可查看对象头信息

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.14</version>
</dependency>

对象头:对象头存储的是对象在运行时状态的相关信息、指向该对象所属类的元数据的指针,如果对象是数组对象那么还会额外存储对象的数组长度

实例数据:实例数据存储的是对象的真正有效数据,也就是各个属性字段的值,如果在拥有父类的情况下,还会包含父类的字段。字段的存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响

对齐填充字节:在java对象中,需要对齐填充字节的原因是,64位的jvm中对象的大小被要求向8字节对齐,因此当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作。注意图中对齐填充部分使用了虚线,这是因为填充字节并不是固定存在的部分。

对象头内容:
  1. 类型指针(Klass Pointer)
    是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;

  2. 标记字段(Mark Word)
    用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键.
    在这里插入图片描述

  3. 数组长度(如果对象为数组的话)

锁实现

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,并在线程栈中创建一个LR(锁记录)并将markword拷贝到LR中。如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

轻量级锁

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,说明这个锁对象已经被其他线程线程抢占了,继续尝试(自旋)获取轻量级锁,当失败多次后,就升级为重量级锁。

自旋锁
在轻量级锁中获取锁失败后会继续尝试获取锁,这个流程就称为自旋;如果锁的粒度很小持有锁的时间很短,通过轮询几次以后就可以获取锁这个成本相对重量级锁而言还是很低的。
使用-XX:-UseSpinning参数关闭自旋锁优化;-XX:PreBlockSpin参数修改默认的自旋次数。

自适应自旋锁
自旋意味着占用CPU资源,如果线程多,CPU资源比较少时,CPU资源全部被自旋占用,反而造成了CPU资源的浪费。那么如何设置自旋的次数呢?这个是一件很困难的事情。自适用自旋锁就是为了解决锁竞争的时间不确定性这个问题的。JVM自己去根据锁竞争的情况去优化自旋次数。

重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。在这个线程释放锁之后,其他线程进入就绪状态竞争锁。
锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的),省略部分属性

ObjectMonitor() {
    _count        = 0; //记录数
    _recursions   = 0; //锁的重入次数
    _owner        = NULL; //指向持有ObjectMonitor对象的线程 
    _WaitSet      = NULL; //调用wait后,线程会被加入到_WaitSet
    _EntryList    = NULL ; //等待获取锁的线程,会被加入到该列表
}

对于一个synchronized修饰的方法(代码块)来说:

当多个线程同时访问该方法,那么这些线程会先被放进_EntryList队列,此时线程处于blocked状态
当一个线程获取到了对象的monitor后,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的/_owner指向当前线程,_count加1表示当前对象锁被一个线程获取
当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的/_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程进入_EntryList队列,竞争到锁再进入_Owner区
如果当前线程执行完毕,那么也释放monitor对象,ObjectMonitor对象的/_owner变为null,_count减1
由此看来,monitor对象存在于每个Java对象的对象头中(存储的是指针),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

ReentrantLock原理

ReentrantLock底层使用了CAS+AQS队列实现

CAS(Compare and Swap)

CAS是一种无锁算法。有3个操作数:内存值V、旧的预期值A、要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

do{
  备份旧数据;
  基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))

该操作是一个原子操作,被广泛的应用在Java的底层实现中。

在Java中,CAS主要是由sun.misc.Unsafe这个类通过JNI调用CPU底层指令实现。

ABA问题

ABA问题带来的危害:
小明在提款机,提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50
线程1(提款机):获取当前值100,期望更新为50,
线程2(提款机):获取当前值100,期望更新为50,
线程1成功执行,线程2某种原因block了,这时,某人给小明汇款50
线程3(默认):获取当前值50,期望更新为100,
这时候线程3成功执行,余额变为100,
线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!
此时可以看到,实际余额应该为100(100-50+50),但是实际上变为了50(100-50+50-50)这就是ABA问题带来的成功提交。

解决,使用版本号,每次修改版本号+1
AtomicStampedReference,在Pair里面保存了值reference和时间戳stamp,整个pair进行CAS。

AQS

AQS是一个用于构建锁和同步容器的框架。
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
AQS使用一个FIFO的队列(也叫CLH队列,是CLH锁的一种变形),表示排队等待锁的线程。队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus,通过此状态判断节点线程是否取消等待。

在这里插入图片描述
补充:入队时从后往前便利队列,清除Cancel节点为啥从后往前?

入队时为啥将前驱节点ws设置为signal?
告诉前驱节点释放时记得通知

共享锁

reentrantreadwritelock

特性

内部分为读锁和写锁,可以有N个读操作线程获取到写锁,但是只能有1个写操作线程获取到写锁

原理

读写锁还是沿用的AQS中的state变量。高16位表示读锁的数量,低16位表示写锁的数量。
如果用两个变量的话,在很多场景需要同时判断读锁和写锁的状态比如在获取写锁的时候需要确保读锁为0写锁也只能是自己的重入,所以使用一个变量更加方便。

ThreadLocal完成线程的可重入计数

锁降级

使用层面,写锁可以“降级”为读锁;读锁不能“升级”为写锁

假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。但是读写锁的升级并不是不可能的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java中的机制是线程同步的关键,它用于控制多个线程对共享资源的访问,以避免数据竞争和并发问题。Java主要由`synchronized`关键字、`Lock`接口(如`ReentrantLock`)以及`Condition`类来实现。 **底层原理概述:** 1. **监视器(Monitor)和定:**每个对象都关联着一个监视器,当一个线程进入对象的`synchronized`代码块或方法时,它会获取该对象的监视器。如果另一个线程尝试获取同一对象的,那么线程会被阻塞直到第一个线程释放。 2. **互斥(Mutual Exclusion):**同一时间只有一个线程可以持有对象的,这就是互斥原则。其他等待的线程必须等到当前线程执行完毕并释放。 3. **可见性(Visibility):**当一个线程修改了共享状态后,必须调用`notify()`或`notifyAll()`方法通知其他等待的线程,使它们能够重新检查条件并获得。 4. **等待(Waiting)和唤醒(Signal):**`wait()`方法会让当前线程放弃,进入等待状态,直到被其他线程调用`notify()`或`notifyAll()`唤醒。如果等待过程中线程被中断,它会抛出`InterruptedException`。 5. **重入(Recursion):`synchronized`关键字支持一个线程在同一对象上多次获取,但只有在未被其他线程占用时才允许。 **ReentrantLock扩展:**Java的`ReentrantLock`提供了更多的灵活性,比如可选择公平(按等待线程顺序获取)和非公平(最快的线程优先获取),以及显式的获取和释放。此外,它还提供`tryLock()`方法用于无操作,以及条件变量`Condition`用于更复杂的同步场景。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值