一、乐观锁:
不会对资源加锁,只是更新共享资源时,判断是否允许更新。
1.1 CAS(Compare and Swap)思想:
它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。
如果内存位置的值与预期原值相等,则将该位置的值更新为新值,否则不做任何操作
1.1.1 使用 Unsafe.compareAndSwapObject() 保证线程安全用例
如下的用例中 UNSAFE.compareAndSwapObject(unsafeCASTest, I_OFFSET, unsafeCASTest.i, unsafeCASTest.i + 1); 完成了多线程安全的 i+1 操作。
/**
* @Author Snail
* @Describe 通过Unsafe对多个线程操作i++实现线程安全问题
* @CreateTime 2020/3/11
*/
public class UnsafeCASTest {
private int i = 0;
private static Unsafe UNSAFE;
static long I_OFFSET;//i的偏移量
static {
try {
//该方法无法获取到UNSAFE类,需要通过下面的反射去获取
// UNSAFE = Unsafe.getUnsafe();
// 获取 Unsafe 内部的私有的实例化单例对象
Field field = Unsafe.class.getDeclaredField("theUnsafe");
// 无视权限
field.setAccessible(true);
UNSAFE = (Unsafe) field.get(null);
I_OFFSET = UNSAFE.objectFieldOffset(UnsafeCASTest.class.getDeclaredField("i"));
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
}
@Test
public void main() {
UnsafeCASTest unsafeCASTest = new UnsafeCASTest();
new Thread(new ThreadAdd(unsafeCASTest)).start();
new Thread(new ThreadAdd(unsafeCASTest)).start();
new Scanner(System.in).nextLine();//bio阻塞当前执行线程
}
class ThreadAdd implements Runnable {
private final UnsafeCASTest unsafeCASTest;
public ThreadAdd(UnsafeCASTest unsafeCASTest) {
this.unsafeCASTest=unsafeCASTest;
}
@Override
public void run() {
while (true) {
// i++;
boolean b = UNSAFE.compareAndSwapObject(unsafeCASTest, I_OFFSET, unsafeCASTest.i, unsafeCASTest.i + 1);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "::" + i);
}
}
}
}
1.1.2 Java基于 CAS 提供的操作类
java.util.concurrent.atomic 包下的 AtomicInteger、AtomicBoolean、AtomicLong 等,使用了 CAS 机制,保证了在多线程环境下,操作同一个变量的安全性。
//关于AtomicInteger的一个简单用例
AtomicInteger atomicInteger=new AtomicInteger(0);
int i = atomicInteger.addAndGet(2);
System.out.println(i);//2
System.out.println(atomicInteger);//2
int andAdd = atomicInteger.getAndAdd(3);
System.out.println(andAdd);//2
System.out.println(atomicInteger);//5
CAS的思考:
Q1:CAS包含了Compare和Swap两个操作,它又如何保证原子性呢?
A:CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。
Q2:CAS 有那些缺点?
A:
1. ABA问题
2. 高竞争环境下,自旋对CPU资源的消耗
3. 不够灵活,只能保证一个共享变量的原子操作,涉及到多个变量的同步时,CAS无法保证安全
Q3:如何解决 CAS 的缺点?
A:
1.ABA 问题的出现,是由于对变量的版本缺少了比较,所以我们可以使用 AtomicStampedReference(V initialRef,int initalStamp) ,加入了版本号对比,避免出现 ABA 问题
2. 控制自旋的次数:JDK 6后引入了自适应型自旋锁,同时也可以在代码中控制自旋的时间或次数。
1.2 版本号控制:
当要提交一个更新的时候,比较读取时候的版本号,如果版本号一致,允许提交更新,否则返回失败。多用于 DB 的数据安全控制,如MVCC机制。
二、悲观锁:
开始操作时,就对共享资源加锁,操作完成后再释放锁。
2.1 Lock(ReentrantLock)与 Synchronized 的对比
类别 | synchronized | Lock |
---|---|---|
存在层次 | Java的关键字,在jvm层面上 | 是一个类 |
锁的获取 | 关键字即可完成加锁 | lock.lock() , lock.tryLock() , lock.tryLock(timeout, unit) |
锁的释放 | 1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm会让线程释放锁 (在其底层原理可以看到) | 必须在finally中释放锁,不然容易造成线程死锁 |
锁当前状态 | 无法判断(可能会出现A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 ) | 可以判断(trylock,避免长时间等待) |
锁类型 | 可重入、不可中断、非公平 | 可重入、可中断、可公平(初始化时传入布尔值) |
唤醒 | notify()/notifyAll()随机唤醒一个线程或唤醒全部线程 | 使用 Condition 结合 await()/singal() 实现线程的精确唤醒 |
适用场景 | 少量同步竞争 | 大量同步竞争 |
2.2 ReentrantLock
Lock 接口及其特点:
1. 可重入锁:
同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程等待锁而出现卡死的情况。但需要开发人员保证加锁和解锁的操作成对出现。
重入锁源码中,通过 private volatile int state 变量来记录重入次数。state==0 时,表示锁是空闲的(任意线程都可以加锁成功),大于零表示锁已经被占用, 其数值表示当前线程重复占用这个锁的次数。
java.util.concurrent.locks.ReentrantLock.NonfairSync#lock
final void lock() {
// compareAndSetState就是对state进行CAS操作,如果修改成功就占用锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//如果修改不成功,说明 有线程 已经在使用了这个锁,那么就 可能 需要等待
acquire(1);
}
java.util.concurrent.locks.AbstractQueuedSynchronizer#acquire
public final void acquire(int arg) {
//tryAcquire() 再次尝试获取锁,
//如果发现锁就是当前线程占用的,则更新state,表示重复占用的次数,
//同时宣布获得所成功,这正是重入的关键所在
if (!tryAcquire(arg) &&
// 如果获取失败,那么就在这里入队等待
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//如果在等待过程中 被中断了,那么重新把中断标志位设置上
selfInterrupt();
}
2. 公平锁:
ReentrantLock 默认非公平锁,可在实例化时传入 boolean 值决定锁的类型。
tryAcquire 是获取锁的一个操作,在公平锁的实现中,按进入锁队列的先后顺序为线程分配锁,先进入的线程优先获取锁。
java.util.concurrent.locks.ReentrantLock.FairSync#tryAcquire
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
//先看看有没有别人在等,没有人等我才会去抢,有人在我前面 ,我就不抢啦
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
3. 可中断锁:
在等待锁的过程中可以响应中断,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况
可中断锁的使用: lock.lockInterruptibly() 或者 lock.tryLock(timeout, unit) throws InterruptedException
2.2.1 ReentrantReadWriteLock
ReentrantReadWriteLock 中包含读锁和写锁,其中读锁是可以多线程共享的,即共享锁,而写锁是排他锁,一把排他锁不予许与额外的锁同时存在。
ReentrantLock 与 ReentrantReadWriteLock 的区别:
1. ReentrantReadWriteLock 细分读锁和写锁是为了提高效率,将读和写分离,对比 ReentrantLock 可以发现,无论并发读还是写,它总会先锁住全部再说。
2. ReentrantReadWriteLock 进一步提高了多线程锁竞争环境下,代码的吞吐量。
2.2.2 底层的实现
ReentrantLock:主要使用 AbstractQueuedSynchronizer(AQS)中的 volatile 修饰的同步状态位state、CAS 修改 state 和 CLH 的线程队列实现的一种独占锁。
2.3 Synchronized
2.3.1 JDK 6 后对 Synchronized 的优化
Synchronized 是从 JVM 层面为我们提供的单机锁,当线程访问同步块时首先需要获得锁并把相关信息存储在对象头中,对象头主要有由两部分组成:
- Mark Word :存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
- Klass Pointer:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
Mark Word在不同的锁状态下存储的内容不同,64 位虚拟机 Mark Word 是 64bit ,其结构如下:
- biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
- 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
- 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
- 分代年龄(age):表示对象在 YoungGeneration 被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。4bit表示最大GC次数为1111(二进制)=15次。
- 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
- epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
- ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
- ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
从 Mark Word 中可以看到,锁有3种状态:
1. 偏向锁:减少同一线程多次获取同一个锁的性能消耗
检查 Mark Word 中的线程 id 是否为本线程,是则加锁成功,否则撤销偏向锁
适用带有同步但只有一个线程处理的程序性能,但如果存在竞争会带来额外的锁撤销操作。
偏向锁在 JDK 6 及以后的 JVM 里是默认启用的。可以通过参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
2. 轻量级锁:多个线程竞争偏向锁升级为轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在 JVM 中的实现是:在当前线程的栈帧中创建 Lock Reocrd,并将对象头中的 Mark Word 复制到 Lock Reocrd 中。然后使用 CAS 尝试将 Mark Word 替换为指向 Lock Reocrd 的指针。
适用多线程交替执行同步块的情况,但如果多个线程同时 抢占锁 就会升级为重量级锁
3. 重量级锁:将除了拥有锁的线程以外的线程都阻塞
锁状态升级总结:
- 上述的3种锁状态,只能升级不能降级
- 偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞
除了上述对锁状态的优化,还有如下的一些处理:
自适应性自旋锁:为避免线程间的频繁切换,JVM按情况给出适合的自旋次数
自旋是一种获取锁的机制,它会占用 CPU 的时间。
自旋避免了线程被挂起,提高了线程的响应速度,但如果线程长期无法获取锁,自旋时间过长时,会升级为重量级锁。
锁消除:对检测到不可能存在共享数据竞争的锁进行消除
锁粗化:当一连串的操作都在对同一个对象加锁时,JVM会扩大锁范围
参考资料:
更多锁优化信息的详细介绍
美团-锁特性的对比
小米-synchronized的分析
底层的实现
synchronized 为重量级锁时,没有竞争到锁的对象会 park 被挂起,退出同步块时 unpark 唤醒后续线程
ObjectMonitor 为其底层使用的对象,包含两个同步队列,一个阻塞队列。多线程竞争时,不断的从同步队列中取任务来执行
适用场景
乐观锁适用于线程间竞争较少的情况,因为这种场景下,乐观锁可以通过重试的机制,去避免了线程的阻塞和加锁操作。
悲观锁适用线程间竞争较多的情况,因为这种场景下,乐观锁的大量重试,会导致 CPU 被占用,故而可以使用悲观锁提前锁住资源,会有更高的效率。