悲观锁与乐观锁
可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁,可一定程度避免死锁
共享锁与独占锁
互斥锁与读写锁:独享锁/共享锁的具体实现
公平锁与非公平锁
分段锁:一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作
偏向锁/轻量级锁/重量级锁
自旋锁:当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。默认次数为10次,可以通过参数-XX:PreBlockSpin来调整
自适应自旋锁:自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
synchronized加锁的四种方式
public class SynchronizedTest {
// 静态方法上加synchronized关键字,锁是当前类的class对象
public static synchronized void sync1() {
}
// 普通方法上加synchronized关键字,锁是当前实例对象,与sync1仅作用域不同
public synchronized void sync2() {
}
// synchronized代码块,锁是括号里面的对象
public void sync3() {
synchronized (this) {
}
}
// synchronized代码块,class上加锁,与sync3仅作用域不同
public void sync4() {
synchronized (SynchronizedTest.class) {
}
}
}
查看汇编代码,执行javac -encoding UTF-8 SynchronizedTest.java、javap -v SynchronizedTest.class
public static synchronized void sync1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED // ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 19: 0
public synchronized void sync2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 23: 0
public void sync3();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0 // 将第一个引用类型本地变量推送至栈顶
1: dup // 复制栈顶一个字长的数据,将复制后的数据压栈
2: astore_1
3: monitorenter // 加锁
4: aload_1
5: monitorexit // 释放锁
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
public void sync4();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
// ldc将常量池中#2推送至栈顶,即class com/SynchronizedTest
0: ldc #2 // class com/SynchronizedTest
2: dup
3: astore_1
4: monitorenter // 加锁
5: aload_1
6: monitorexit // 释放锁
7: goto 15
10: astore_2
11: aload_1
12: monitorexit // 释放锁
13: aload_2
14: athrow
15: return
- 对于同步方法
- JVM采用ACC_SYNCHRONIZED标记符来实现同步。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁(monitor),然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被堵塞。
- 同步方法是隐式的,会在运行时常量池中的method_info结构体中存放ACC_SYNCHRONIZED标识符access_flags
无论是同步方法还是同步代码块都是基于监视器Monitor实现
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor中有几个关键属性:
ObjectMonitor中有几个关键方法:
Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,状态转换需要花费很多的处理器时间。
- Klass Word(类指针):存储对象的类型指针,该指针指向它的类元数据。从JDK 1.6 update14开始,64位的JVM正式支持了-XX:+UseCompressedOops(默认开启),可以压缩指针,起到节约内存占用的作用。oop(ordinary object pointer)即普通对象指针,下列指针将压缩至32位:
- 每个Class的属性指针(静态成员变量)
- 每个对象的属性指针(对象变量)
- 普通对象数组的每个元素指针
- 指针压缩:
- 如果GC堆大小在4G以下,直接砍掉高32位,避免了编码解码过程(偏移量除以/乘以8)
- 如果GC堆大小在4G以上32G以下,则启用-XX:+UseCompressedOops命令
- 如果GC堆大小大于32G,压指失效,使用原来的64位
- -XX:+UseCompressedClassPointers
- Java8使用Metaspace存储元数据,开启后类元信息中的指针也用32bit的Compressed版本,即Klass Word
- 依赖-XX:+UseCompressedOops
- 32位虚拟机占用32个字节,不同状态下各个比特位区间大小有变化
- biased_lock:偏向锁标记,为1时表示对象启用偏向锁
- age:默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15
- identity_hashcode
- 采用延迟加载技术,只有在需要时使用System.identityHashCode(Object x)计算后写到该对象头中
- 偏向锁没有存储HashCode的地方,偏向锁期间调用System.identityHashCode(x)会造成锁升级
- 轻量级锁和重量级锁所指向的lock record或monitor都有存储HashCode的空间
- 用户自定义hashCode()方法所返回的值不存在Mark Word中,只针对identity hash code
// 引入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.11</version>
</dependency>
// 查看对象布局信息
ClassLayout layout = ClassLayout.parseInstance(new A());
System.out.println(layout.toPrintable());
可通过ClassLayout查看对象布局信息,即对象占用空间情况
锁升级是单向的: 无锁 -> 偏向锁 -> 轻量级锁(自旋锁和自适应自旋锁) -> 重量级锁
持有偏向锁的线程不会主动释放偏向锁,而是等待其他线程来竞争才会释放锁。这样偏向锁保证了总是同一个线程多次获取锁的情况下,每次只需要检查标志位就行,效率很高
当检测到偏向锁且不归属当前线程,会暂停原持有偏向锁线程,检测其执行状态:
- 如果偏向锁线程已退出同步代码块,清除偏向锁标识,转为无锁状态,由当前线程获取轻量级锁
- 如果偏向锁线程未退出同步代码块,由其膨胀为轻量级锁
JIT编译器借助逃逸分析(Escape Analysis)技术来判断同步块所使用的锁对象是否只能够被一个线程访问,如果被证实,就会取消对这部分代码的同步
public class EscapeAnalysis {
public static Object object;
public Object methodEscape1() { // 方法逃逸:方法返回值逃逸
return new Object();
}
public Object methodEscape2() { // 方法逃逸:作为参数传递到其它方法中
Object object=new Object();
xxx(object)
}
public void threadEscape1() {// 线程逃逸:赋值给类变量
object = new Object();
}
public void threadEscape2() { // 线程逃逸:其他线程中访问的实例变量
Object obj=new Object();
new Thread(() -> xxx(obj)).start();
}
public void eliminate1() { // o未逃逸,可自动清除锁
Object o=new Object();
synchronized (o){
xxx();
}
}
public void eliminate2() { // buffer未逃逸,append操作加锁可自动清除锁
StringBuffer buffer=new StringBuffer();
buffer.append("hello");
buffer.append("world");
buffer.append("!");
}
}
- 标量替换:把不存在逃逸的对象拆散,将成员变量恢复到基本类型,直接在栈上创建若干个成员变量
- 栈上分配:目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换。栈上分配随着方法结束而自动销毁,垃圾回收压力减小
尽量减小锁的粒度,可以避免不必要的阻塞。但是如果在一段代码中连续的用同一个监视器锁反复的加锁解锁,甚至加锁操作出现在循环体中的时候,就会导致不必要的性能损耗,这种情况就需要锁粗化。
for(int i=0;i<100000;i++){
synchronized(this){
do();
}
会被粗化成:
synchronized(this){
for(int i=0;i<100000;i++){
do();
}
特性
API
能响应中断
lockInterruptbly()
非阻塞式的获取锁
tryLock()
支持超时
tryLock(long time, timeUnit)
可实现公平锁
ReentrantLock(ture)
可以绑定多个条件
newCondition()
Lock lock = new ReentrantLock();
lock.lock();
try{
...
}finally{
lock.unlock();
}
基于AbstractQueuedSynchronizer,队列同步器实现
setState():拥有锁的线程调用本身具有原子性,不需要使用cas进行设置
- 右下方【线程进入等待状态】的流程图中“结束”不是真正意思上的结束,外层是一个死循环。只有前驱节点为头结点,且获取同步状态成功才会退出循环。节点的就绪、挂起、获取同步都是在循环里完成的,很重要!!!
- 响应中断获取同步状态只是在中断检测的处理方式上不同,Thread.interrupted()检测到中断状态后直接抛出了InterruptedException
- LockSupport.park(),线程挂起
- 调用Thread的interrupt方法,设置中断标识为true,且内部会调用Parker::unpark(),唤醒挂起线程
- 调用Thread.interrupted()返回并清除中断标识
- 中断状态为true抛出异常
graph TB
A((非公平模式))-->B{CAS抢锁}
B-->|成功|D((成功))
B-->|失败|C{state=0}
C-->|是|G{抢锁}
G-->|成功|D
G-->|失败|F
C-->|否|E{重入}
E-->|是|D
E-->|否|F((进队列))
A1((公平模式))-->C1{state=0}
C1-->|是|B1{AQS队列}
B1-->|为空|D1{CAS抢锁}
B1-->|不为空|F1
D1-->|成功|E1((成功))
D1-->|失败|F1
C1-->|否|G1{重入}
G1-->|是|E1
G1-->|否|F1((进队列))
graph TB
A((开始))-->B{tryRelease}
B-->|成功|D{后继节点}
D-->|为空或者取消状态|E[倒序查找有效后继节点]
D-->|否|F[唤醒后继节点]
B-->|失败|C[失败]
E-->F
- tryRelease:getState() - releases是否等于0,即释放后state==0
- 后继节点:头结点的后继节点,修改head的waitStatus=0并唤醒后继节点(head的waitStatus=0不执行唤醒后继节点)。非公平模式下,被唤醒的后继节点有可能抢锁失败,会再次把head的waitStatus修改为-1,自旋再次抢锁,若再失败线程挂起,等待下次唤醒
- 倒序查找有效后继节点
- 节点加入双向队列时,双向链表的建立非原子操作,先建立的是Prev指针(正常查找可能找不到该节点)
- 取消节点时,先断开的是Next指针,Prev指针并未断开(查找可能中断)
- 支持写锁降级,不支持读锁升级
- 非公平读锁下为了避免写锁饥饿,会判断头节点的下一个节点是否为排他节点(即写请求),如果是,当前的读锁堵塞
- 每个线程持有读锁的次数,使用ThreadLocal记录
- 使用写锁时需要先释放读锁,如果有两个读取锁试图获取写入锁,且都不释放读取锁时,就会发生死锁。因为写锁是排它锁,两个线程都会因为有其他线程持有读锁而无法获取写锁
- 读锁不支持Condition(读锁在某一时刻最多可以被多个线程拥有,对于读锁而言,其他线程没有必要等待获取读锁,等待唤醒是毫无意义的)
JDK 8新增的读写锁StampedLock,跟读写锁ReentrantReadWriteLock不同,它并不是由AQS实现,有三种访问模式:
StampedLock可以将三种模式是锁进行有条件的互相转换
tryConvertToWriteLock():将其他锁转换为写锁
tryConvertToReadLock:将其他锁转换为读锁
tryConvertToOptimisticRead:将其他锁转换为乐观锁
Oracle 官方的例子:
class Point {
private double x, y;// 成员变量
private final StampedLock sl = new StampedLock();// 锁实例
/**
* 写锁writeLock
* 添加增量,改变当前point坐标的位置。
* 先获取到了写锁,然后对point坐标进行修改,然后释放锁。
* 写锁writeLock是排它锁,保证了其他线程调用move函数时候会被阻塞,直到当前线程显示释放了该锁,也就是保证了对变量x,y操作的原子性。
*/
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
/**
* 乐观读锁tryOptimisticRead
* 计算当前位置到原点的距离
*/
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 尝试获取乐观读锁(1)
double currentX = x, currentY = y; // 将全部变量拷贝到方法体栈内(2)
// 检查票据是否可用,即写锁有没有被占用(3)
if (!sl.validate(stamp)) {
// 如果写锁被抢占,即数据进行了写操作,则重新获取
stamp = sl.readLock();// 获取悲观读锁(4)
try {
// 将全部变量拷贝到方法体栈内(5)
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);// 释放悲观读锁(6)
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);// 真正读取操作,返回计算结果(7)
}
/**
* 悲观读锁readLock
* 如果当前坐标为原点则移动到指定的位置
*/
void moveIfAtOrigin(double newX, double newY) {
long stamp = sl.readLock();// 获取悲观读锁(1)
try {
// 如果当前点在原点则移动(2)
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp);// 尝试将获取的读锁升级为写锁(3)
if (ws != 0L) {
// 升级成功,则更新票据,并设置坐标值,然后退出循环(4)
stamp = ws;
x = newX;
y = newY;
break;
} else {
// 读锁升级写锁失败,则释放读锁,显示获取独占写锁,然后循环重试(5)
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);// 释放写锁(6)
}
}
}
参考:
手机扫一扫
移动阅读更方便
你可能感兴趣的文章