java并发锁
下面记录几种java中常用到的并发锁
synchronized
synchronized是java中的一个关键字,被其修饰的方法或代码块在任意时刻只能被一个线程执行。
对象锁:java中每个类对象都存在一个锁对象,该锁被称为对象锁。类中被synchronized修饰的方法执行前会将对象锁上锁,从而使得同一时刻同个对象只能执行一个被synchronized修饰的方法。
类锁:java中每个类都存在一个类的Class对象锁,该锁被称为类锁,针对类而非对象。(相当于静态成员)
-
获取对象锁
-
synchronized(this|object) {}
-
修饰非静态方法
-
-
获取类锁
-
synchronized(类.class) {}
-
修饰静态方法
-
synchronized锁有四种状态:无锁,偏向锁、轻量级锁和重量级锁
无锁:对象被创建但未经任何同步操作,此时线程可以随意使用该对象资源。
对象头中的锁状态为空,锁标志位01,无偏向,这是JVM给的初始值。
偏向锁:如果只有一个线程直接进行加锁和解锁操作,那么就会出现偏向锁状态。在偏向锁状态下,锁会记录下持有偏向锁的线程ID,以后该线程访问同步块的时候就可以直接使用偏向锁而不需要再次操作同步机制。如果有其他线程访问该同步块,则会撤销偏向锁,升级为轻量级锁。
线程刚进入临界区(synchronized)时,发现该对象头的标志位是01且无偏向,则将当前线程id记录到对象头的Mark Ward中,修改偏向为1。若该线程再次进入临界区,则先看对象偏向锁的线程id是否为自己,若是自己则直接执行,效率较高。(此处可以理解为仅作了标记而未真正加锁)
轻量级锁:在偏向锁的基础上,又有另外一个线程进来,这时判断对象头中存储的线程的ID和该线程不一致,就会使用CAS竞争锁,并且升级为轻量级锁
当有其它线程B 也进入临界区了(线程A还没出临界区),就CAS自旋等待很短的时间,如果线程A此时恰好退出临界区了,CAS退出就升级为轻量级锁,否则就是重量级锁。 轻量级锁会构造一个Lock Record锁记录(LockRecord记录了一些占锁线程的相关信息)。轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。
重量级锁:当线程没有获得轻量级锁时,线程会CAS自旋来获取锁,当一个线程自旋10次之后(JVM 有一个计数器记录自旋次数,默认允许循环 10 次,可以修改),仍然未获得锁,那么就会升级成为重量级锁。
Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。
简单用例1(直接获取对象锁):
class MyRunnable implements Runnable{ @Override public void run(){
//获取对象锁 synchronized(this){ try{ int i=(int)Thread.currentThread().threadId(); System.out.println("this is " + i); Thread.sleep(1000); System.out.println("finish " + i); }catch(Exception e){ e.printStackTrace(); } } } } public class synchronizedTest { public static void main(String[] Args){ Runnable myRunnable = new MyRunnable(); Thread[] test = new Thread[5]; for(int i=0;i<5;++i)test[i]=new Thread(myRunnable); var startTime = System.currentTimeMillis(); for(int i=0;i<5;++i)test[i].start(); try{ for(int i=0;i<5;++i)test[i].join(); }catch(Exception e){ } var endTime = System.currentTimeMillis(); System.out.println("spend " + (endTime - startTime)); } }
输出如下:
简单用例2(synchronized 方法):
public class synchronizedTest { private static int i = 0; private static synchronized void inc(){ i++; System.out.println(i); } public static void main(String[] Args){ Thread[] test = new Thread[5]; for(int i=0;i<5;++i)test[i]=new Thread(new Runnable() { @Override public void run(){ inc(); } }); for(int i=0;i<5;++i)test[i].start(); } }
输出如下:
若上面inc()方法去除synchronized关键字,则输出如下(结果不唯一):
可见synchronized关键字可有效保证数据的原子性。
wait,notify,notifyAll
Object对象wait,notify,notifyAll方法为public final
此三方法只能被持有对应对象的对象锁的线程调用,否则会抛出异常
wait方法会让当前线程挂起并释放对象锁。
notify方法会随机唤醒一个被wait方法挂起的线程。
notifyAll会唤醒所有被wait方法挂起的线程。
需注意:
synchronized 关键字修饰静态方法获得的锁是类锁,反之获得的是对象锁
synchronized 关键字修饰的方法,其synchronized特性不会被继承。即父类中synchronized方法在子类中若不覆盖,则默认无synchronized修饰。
定义接口方法或构造方法时无法使用synchronized修饰
synchronized与volatile的区别类似于c++中std::atomic和volatile的区别(见之前文章)。只不过synchronized无法修饰变量,而volatile只能修饰实例变量。
synchronized是可重入锁
Lock接口
Lock接口是java juc包提供的锁接口,提供了以下方法:
- lock(); 获取锁,如果锁不可用,则出于线程调度的目的,当前线程将被禁用,并且在获取锁之前处于休眠状态。
- tryLock(); 试图获取锁,根据获取是否成功返回boolean,该方法不会让线程陷入等待。
- tryLock(long time, TimeUnit unit); 同2,但是会等待参数时间,之后返回结果。
- lockInterruptibly(); 试图获取锁,失败则抛出异常InterruptedException(被中断时),下文有详细解释
- unLock(); 释放锁
可中断:lockInterruptibly():使用synchronized或.lock()方法去获得锁时,若失败则线程会一直处于等待状态,在此期间线程无法被中断,即在主线程调用等待线程的thread.interrupt()方法强行中断无效。若使用.lockInterruptibly()方法去获得锁,则可以让处于等待队列中的线程被主线程使用.interrupt()方法强行中断,同时被中断线程会抛出异常InterruptedException
介绍lock具体实现之前还得先引入AQS
AQS(队列同步器)
AQS ( Abstract Queued Synchronizer )是一个抽象的队列同步器,通过维护一个共享资源状态( Volatile Int State )和一个先进先出( FIFO )的线程等待队列来实现一个多线程访问共享资源的同步框架。
AQS是JUC包中多个组件的底层实现,如Lock、CountDownLatch、Semaphore等都用到了AQS。AQS为共享资源设置锁,让锁拥有者得到使用共享资源的权力,而其他线程则在FIFO的等待队列中等待
AQS对于共享资源的状态是用一个volatile int类型的变量state来记录,并提供了getState()、setState()和 compareAndSetState()三种原子操作来获取和修改state。
AQS定义了两种资源共享的方式,独占式和共享式。独占式即同一时刻仅有一个线程可持有共享资源,如ReentrantLock;共享式即同一时刻允许多个线程持有共享资源,如Semaphore和CountDownLatch
AQS只是一个框架 ,只定义了一个接口,具体资源的获取、释放都由自定义同步器去实现。不同的自定义同步器争用共享资源的方式也不同,自定义同步器在实现时只需实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护,如获取资源失败入队、唤醒出队等, AQS 已经在顶层实现好,不需要具体的同步器再做处理。
自定义同步器的主要方法如下:
ReentrantLock(可重入锁)
ReentrantLock是可重入的互斥锁,与synchronized效果类似,但是比synchronized更加灵活,具有许多特性。
ReentranLock的结构比较简单,如下图所示:
可以说,ReentranLock的核心是Sync,Sync又细化了两个子类,NonFairSync和FairSync,对应了ReentranLock的两种模式,非公平模式和公平模式。
非公平模式NonFairSync:前面提到过,AQS模型中,共享资源获取失败后线程会到FIFO等待队列中,当持有资源的线程释放资源时,等待队列头的线程会重新被唤醒并参与资源的竞争。为什么不是直接获取资源而是竞争呢?因为此时可能会有其他不在等待队列中的线程去争夺资源。而非公平即体现在此处,队头线程还要和刚来竞争的线程去抢夺资源。sync中专门为NonFairSync提供了一个函数nonfairTryAcquire,NonFairSync直接使用Sync提供的这个函数去实现AQS留下的接口方法tryAcquire,最终在AQS的acquire函数中被调用。
公平模式FairSync:正如其名,公平模式所实现的tryAcquire方法中,其只会人等待队列中的元素获取共享资源,非等待队列的元素竞争资源只会失败,进入等待队列等待。
ReentranLock默认构造函数ReentranLock()是非公平策略,若构造时用ReentranLock(true)则为公平策略。
可重入:即已经拥有该锁的对象,若再次申请访问该锁,则可以无需竞争,直接进入,避免了死锁的产生。
ReentrantLock中的state初始值为0表示无锁状态。在线程执行 tryAcquire()获取该锁后ReentrantLock中的state+1,这时该线程独占ReentrantLock锁,其他线程在通过tryAcquire() 获取锁时均会失败,直到该线程释放锁后state再次为0,其他线程才有机会获取该锁。该线程在释放锁之前可以重复获取此锁,每获取一次便会执行一次state+1, 因此ReentrantLock也属于可重入锁。 但获取多少次锁就要释放多少次锁,这样才能保证state最终为0。如果获取锁的次数多于释放锁的次数,则会出现该线程一直持有该锁的情况;如果获取锁的次数少于释放锁的次数,则运行中的程序会报锁异常。
可中断:lockInterruptibly()实现,前文有解释。
ReentrantReadWriteLock(读写锁)
- writeLock():获取写锁。
- readLock():获取读锁。
ReentrantReadWriteLock是ReadWriteLock接口的实现,读写锁维护了一对锁,读锁和写锁()
public interface ReadWriteLock {
Lock readLock(); //用于获取读锁,读锁之间不相互阻塞
Lock writeLock(); //用于获取写锁,写锁和其它锁互斥
}
ReentrantReadWriteLock的readLock和writeLock同样依赖Sync实现具体功能,支持公平模式和非公平模式
ReentrantReadWriteLock的写锁由WriteLock实现,是独占锁且可重入,性质类似于ReentrantLock。
ReentrantReadWriteLock的读锁由ReadLock实现,读锁的 lock方法调用了 AQS 的 acquireShared方法,其内部sync重写了tryAcquireShared。其首先会获取当前写锁的AQS的state,若写锁被其他线程持有,则会进入等待队列。若没有线程持有写锁,或是当前线程已持有写锁,则可获取读锁。
ReentrantReadWriteLock 巧妙地使用 AQS 的状态值的高 16 位表示获取到读锁的个数,低 16位表示获取写锁的线程的可重入次数,并通过 CAS对其进行操作实现了读写分离,在读多写少的场景下适用
参考文章:https://blog.csdn.net/qq_40322236/article/details/127254744
https://blog.csdn.net/zhanglong_4444/article/details/93761890
https://zhuanlan.zhihu.com/p/347881762