锁的常缩写用名词简介
CAS(Compare And Swap):
乐观锁 并发策略先进行对数据的操作,如果没有发现其它线程也操作了数据,那么就认为这个操作是成功的。如果发生了其它线程也操作了数据,那么一般采取不断重试的手段,直到成功或最大重试次数为止,这种乐观锁的策略,不需要把线程阻塞,属于 非阻塞同步 的一种手段。
PCC(Pessimistic Concurrency Control):
悲观锁 在整个数据处理过程中,将数据处于锁定状态。禁止其他线程、事务进行修改。典型如数据库中的行锁,Java中的synchronized。
AQS(AbstractQueuedSynchronizer):
抽象队列同步器。AQS就是基于 CLH 队列,用一个state标记位,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。支持 独占 和 共享 两种方式。
AQS里面的CLH队列是CLH同步锁的一种变形。其主要从两方面进行了改造:节点的结构 与 节点等待机制 :
- 节点结构上引入了头结点和尾节点,他们分别指向队列的头和尾,尝试获取锁、入队列、释放锁等实现都与头尾节点相关,并且每个节点都引入前驱节点和后后续节点的引用(双向链表)
- 等待机制由原来的自旋改成 自旋 + 阻塞 + 唤醒
ReentrantLock就是基于 AQS 实现的
CLH(Craig,Landin and Hagersten)发明人名字:
- 当线程获取锁失败后入队尾,指针指向自己的前一个节点
- 线程自旋判断前驱节点为头节点则尝试获取锁,成功则将自己设置为头结点。失败则判断前驱节点waitStatus是否为-1。如果是就进入阻塞状态等待被唤醒,否则修改waitStatus为-1等待下一次自旋。(如前驱节点不是头结点则直接进入判断waitStatus是否为-1的流程)
- 释放锁的时候判断自身的waitStatus如果 != 0 则说明后继节点正在阻塞,等待被唤醒,所以唤醒后继线程(有效的避免了 惊群效应 )。
Synchronized详解
synchronized是什么
synchronized是Java提供的一个并发控制的关键字(JVM层面),作用于 对象 上。主要有三种用法:
- 非静态方法:锁作用于访问的对象上
- 静态方法:锁作用于方法所在的类对象class
- 代码块:锁作用于括号中指定的对象
synchronized的历史
JDK1.6以前:synchronized 那时还属于重量级锁。
JDK1.6及以后:synchronized 引入锁升级策略(只能升级不能降级),依次为 无锁 →偏向锁 → 轻量级锁 → 重量级锁 。(因为大多数时间都不会发生多个线程同时竞争锁的情况,每次线程都加锁解锁,每次这么搞都要操作系统在用户态和内核态之间来回切,太耗性能了。)
偏向锁、轻量级锁、重量级锁优缺点对比
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 使用于基本只有一个线程访问同步块的场景 |
轻量级锁 | 多个线程竞争不会阻塞,提高程序响应速度 | 如果始终得不到锁,线程自旋会消耗CPU | 追求响应时间,同步代码块执行耗时短 |
重量级锁 | 多个线程竞争不自旋,节省CPU消耗 | 线程阻塞,需要唤醒,相对耗时较长 | 追求吞吐量,同步代码块执行耗时较长 |
- 偏向锁:
- 对象头 信息 Mark Word 里存储锁偏向的 线程ID ,同一个线程会自动获取锁。
- 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。
- 如果确定同步方法会被高并发的访问,建议通过-XX:-UseBiasedLocking 参数关闭偏向锁
- 轻量级锁:
- 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过 自旋 的形式尝试获取锁,不会阻塞,从而提高性能。
- 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
- 重量级锁:
- 此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态等待被唤醒。通过对象内部的监视器(Monitor)实现。
- Monitor对象重要属性说明(并非所有的属性):
- _owner:指向持有ObjectMonitor对象的线程,当线程释放monitor时,_owner又恢复为NULL。
- _WaitSet:存放处于wait状态的线程队列,因为调用wait方法而被阻塞的线程会被放在该队列中
- _EntryList:存放处于等待锁block状态的线程队列
- _recursions:锁的重入次数
- _count:用来记录该线程获取锁的次数(当_count = 0、_recursions = 0 则释放锁,唤醒EntryList中的线程)
既然聊到了这里咱在了解下对象头。
对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)
Mark Word:默认存储对象的HashCode、分代年龄、GC次数和锁标志位信息。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
锁升级流程
Lock对比Synchronized
对比项 | Synchronized | Lock |
---|---|---|
存在层次 | JAVA关键字,JVM层面 | 是一个接口 |
锁释放 | 1. 获取锁的线程执行完同步代码,释放锁 2. 程执行发生异常,jvm会让线程释放锁 | 调用unlock()方法释放锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | Lock有多种获取锁的方式,如lock、tryLock |
锁状态 | 无法判断 | 可判断 可避免死锁:tryLock(long time, TimeUnit unit) |
锁类型 | 可重入 非公平 不可中断 | 可重入 可公平/非公平 可中断:lockInterruptibly() |
公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
锁粗化
原则上,我们在编写同步块的时候,同步块的范围应当尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能小,如果存在锁竞争,那等待锁的线程也能尽快的拿到锁。
大部分情况下,这种原则是正确的,但是如果一系列的连续操作都需要对同一个对象进行加锁和解锁,甚至加锁操作时出现在循环体中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不需要的性能损耗。这时JIT编译器就会把锁同步的范围扩展(粗化)。
例如我们有如下代码 ↓
for(int i=0;i<size;i++){
synchronized(lock){
...
}
}
锁粗化后的代码如下 ↓
synchronized(lock){
for(int i=0;i<size;i++){
...
}
}
锁消除
锁消除是发生在编译器级别的一种锁优化方式。将检测到不可能存在共享数据竞争的锁进行削除。
例如我们有如下代码 ↓
public void method() {
Object object = new Object();
synchronized (object) {
System.out.println("Hello world");
}
}
object 本身就是局部变量,方法的的局部变量是线程独立的,并发的场景每个线程都有各自的object对象,这个时候的锁就无意义的。
分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁。
典型如 ConcurrentHashMap ,其并发的实现就是通过分段锁的形式来实现高效的并发操作。ConcurrentHashMap中的分段锁称为 Segment ,它即类似于HashMap的结构,即 内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个Hashmap进行加锁,而是先通过 HashCode 来知道他要放在那一个分段中,然后 对这个分段进行加锁 ,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在 统计size 的时候,可就是获取Hashmap全局信息的时候,就需要 获取所有的分段锁 才能统计。分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。