Java 锁 并发 线程同步 学习笔记 2022-3-31
Java中的锁
java中的锁主要用于保障线程在多并发情况下数据的一致性,即多次执行过程中,线程的执行顺序都不相同,但最终的结果始终相同。
通常在使用对象或者调用方法之前加锁,这时如果有其他线程也需要使用该对象或者该方法,则首先要获得锁,如果某个线程发现锁已经被其它线程使用,就会进入锁池(Lock Pool)等待锁的释放,直到其他线程执行完成并释放锁,该线程才有机会获取锁 ---> 与其它的等待线程竞争锁资源。这样就保证了同一时刻只有一个线程持有该对象的锁并修改对象,从而保证数据的安全。
个人意见:从Java内存模型上来看,锁的机制使得对共享数据的操作类似于数据库事务的序列化的隔离界别
序号 | 角度 | 分类 |
---|---|---|
1 | 乐观和悲观 | 乐观锁、悲观锁 |
2 | 获取资源的公平性 | 公平锁、非公平锁 |
3 | 是否共享资源 | 共享锁、独占锁 |
4 | 锁的状态 | 偏向锁、轻量级锁、重量级锁 |
1、乐观锁
乐观锁采用乐观的思想处理数据,认为程序中的并发情况不那么严重,在每次读取数据时都认为别人不会修改该数据,所以不会上锁(没有获取锁与释放锁的操作),但是在更新的时候会判断在此期间别人有没有更新这个数据,以防止自己的更新会覆盖别人的更新,通常采用在写时先读出当前版本号的方式,即使用版本号(version)等机制来实现实现乐观锁。
工作流程:
(1)从主存中,读取要更新的记录和version字段到工作内存中;
(2)修改工作内存的记录;
(3)在保存记录到主存前,从主存中再次读取这个记录的version字段,与修改记录前读取的version字段进行比对;
- (3.1)如果两个version相等,说明从读取记录到修改记录过程中,记录没有被其他人修改过,那么将version字段+1并且保存修改后的记录;
- (3.2)如果两个version不同,说明在这期间version被修改过,则重复进行读取、比较、保存操作。
Java中的乐观锁大部分是通过CAS(Compare And Swap,比较和交换)操作实现,底层依靠cmpxchg指令来实现
CAS是一种原子性操作,CAS(V,E,N)包含三个参数,V表示要更新的变量,E表示预期的指,N表示新值。在且仅在V值等于E值时,才会将V值设置为N,如果V值和E值不相等,说明已经有其他线程做了更新,则本次修改失败
缺点:
1、易造成CPU开销大:在并发量大的情况下,很容发生多个线程反复尝试更新某一个变量,却又一直更新不成功,无疑会给CPU带来很大的压力
2、不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性,比如需要保证扫个3个变量共同进行原子性的更新,就不得不使用synchronized
3、ABA问题:当一个值从A变成B,又更新回A,普通的CAS机制察觉不出来数据被修改过,利用版本号(version)机制可以有效解决ABA问题
2、悲观锁
悲观锁采用悲观思想来读写共享数据,在每次读写共享数据时都认为别人会修改数据,所以每次在读写数据时都会加锁,操作完成后释放锁(防备着其他线程),当别人想要读写这个数据时就会阻塞、等待,直到获取锁后才能执行操作
3、自旋锁
如果持有锁的线程将在很短的时间内释放锁,那么等待获取锁资源的线程就不需要做内核态和用户态之间的切换 ---> 进入阻塞、挂起状态,只需要等一等(也叫做自旋),当持有锁的线程释放锁后,即可立即获取锁资源,这样就避免了用户线程在用户态和内核态之间的频繁切换而导致的时间消耗
注意:线程在自旋时会占用CPU资源,长时间自旋仍然获取不到锁时,将会浪费CPU资源,极端情况下,线程会永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间;当自旋时间超过设定时间后,线程会退出自旋模式。
3.1、自旋锁的优缺点
- 优点:对于占用锁的时间非常短或者锁竞争不激烈时,大幅度提升性能;因为自旋的CPU耗时明显少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间
- 缺点:在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU浪费
3.2、自旋锁的时间阈值
自旋锁用于使当前线程占着CPU资源不释放,自旋时尝试获取锁资源,当自旋时获取锁资源后立即执行相关操作
JDK 1.5为固定的自旋时间,JDK 1.6引入了适应性自旋锁;其自旋时间不再是固定值,而是由上一次在同一个锁上的自旋时间及锁的持有者的状态来决定的,可基本认为是一个线程上下文切换的时间是一个锁自旋的最佳时间
4、synchronized,JVM提供的实现线程同步的关键字
前置知识:每个java对象都可以作为实现同步的锁,这些锁称为内置锁;
线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁;
获得内置锁的唯一途径就是进入这个锁保护的同步代码块或方法
Java内置锁是互斥锁,最多只有一个线程能够获得该锁
当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个锁
如果B线程不释放这个锁,那么A线程将一直等待下去
synchronized 可用于修饰 方法 与 代码块
修饰方法:在方法名的前面加synchronized
修饰代码块:synchronized(对象){//代码块}
- 作用于非静态方法时,锁的是实例对象
- 作用于静态方法时,锁的是当前Class对象,锁住所有调用该静态方法的线程
- 作用于代码块时,锁的是代码块中配置的对象
synchronized修饰方法、代码块时,同一时刻只能有一个线程执行该方法或代码块,其他线程只有等待当前线程执行完毕并释放锁资源后,尝试锁资源,获取到锁资源后才能执行同步方法或同步代码,否则继续等待
工作原理:synchronized关键字通过monitorenter指令和monitorexit指令实现同步
5、ReentranLock
ReentranLock继承了Lock接口并实现了在接口中定义的方法,是一个可重入的独占锁;ReentranLock通过自定义队列式同步器来实现锁的获取与释放;ReentranLock支持公平锁和非公平锁的实现
5.1、ReentrantLock的用法
ReentrantLock有显式的操作过程,何时加锁、何时释放锁都在程序员的控制下
//Step 1:定义一个ReentrantLock
public static Lock lock = new ReentrantLock();
public static int i=0;
public void run(){
for(int j=0;j<10;j++){
lock.lock();//Step 2:加锁
lock.lock();//可重入锁
try{
i++;
}finally{
lock.unlock();//Step 3:释放锁
lock.unlock();//可重入锁
}
}
}
注意:获取锁和释放锁的次数要相同
如果释放锁的次数多于获取锁的次数,Java就会抛出java.lang.IllegalMonitorStateException异常;
如果释放锁的次数少于获取锁的次数,该线程就会一直持有该锁,其他线程将无法获取锁资源
5.2、ReentrantLock如何避免死锁:响应中断、可轮询锁、定时锁
(1)响应中断:在synchronized中如果有一个线程尝试获取一把锁,则其结果是要么获取锁继续执行,要么保持等待;
ReentrantLock还提供跟了可响应中断的可能,即在等待锁的过程中,线程可以根据需要取消对锁的请求
例如:两个线程之间产生了死锁,在等待一定时间后,其中一个线程主动中断并释放锁
(2)可轮询锁:通过boolean tryLock()获取锁;如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false
(3)定时锁:通过boolean tryLock(long time,TimeUnit unit) throws InterruptedException获取定时锁;如果在给定的时间内获取到了可用锁,且当前线程未被中断,则获取该锁并返回true;如果在给定的时间内获取不到可用锁,则禁用当前线程,并在发生以下三种情况之前,该线程一直处于休眠状态
- 1、当前线程获取到了可用锁并返回true
- 2、当前线程在进入此方法时设置了该线程的中断状态,或者当前线程在获取锁时被中断,则抛出InterruptedException,并清除当前线程的已中断状态
- 3、当前线程获取锁的时间超过了指定的等待时间,则将返回false。如果设定的时间小于等于0,则该方法将完全不等待
5.3、Lock接口的主要方法
- void lock():给对象加锁,如果锁正在被其他线程使用,则将阻塞等待,直到当前线程获取到该锁
- boolean tryLock():试图给对象加锁,如果锁未被其他线程使用,则获取锁并返回true,否则返回false
tryLock()和lock()的区别在于前者只是“试图”获取锁,如果没有可用锁,就会立即返回。lock()在获取不到锁时会一直等待,直到获取到可用锁。 - tryLock(long timeout,TimeUnit unit):创建定时锁,如果在给定的等待时间内有可用锁,则获取该锁
- void unlock():释放当前线程锁持有的锁;锁只能由持有者释放,如果当前线程并不持有锁却执行该方法,则抛出异常
- getHoldCount():查询当前线程保持此锁的次数,也就是此线程执行lock()方法的次数
- getQueueLength():返回等待获取此锁的线程估计数,比如启动5个线程,1个线程获得锁,此时返回4
5.4、公平锁与非公平锁
公平锁:在竞争环境下,先到临界区的线程比后到的线程一定先获得锁
非公平锁:先到临界区的线程不一定先获得锁,后来的线程有可能在其之前获得锁
如何实现?
公平锁:将竞争的线程直接放到一个先进先出的队列上,持有锁的线程执行完,唤醒队列中下一个线程去获取锁
非公平锁:新来的线程先不直接进入队列,而是去尝试获取锁,如果获取不到锁,再将其放入到队列中
本质:线程执行同步代码块时,是直接进入到队列中,还是先尝试获取锁再进入到队列中,如果直接进入到队列,那就是公平锁,如果是先获得
ReentrantLock支持公平锁和非公平锁的两种方式;通过在构造函数ReentrantLock(boolean fair)中传递不同的参数来定义不同类型的锁,默认实现的是非公平锁(false-->非公平锁;true-->公平锁)
5.5、tryLock()、lock() 和 lockInterruptibly() 的区别
三者的区别如下:
- tryLock():若有可用锁,则获取该锁并返回true,否则返回false,不会有延迟或者等待
- tryLock(long timeout,TimeUnit unit):定时锁,可以增加时间限制,如果超过了指定的时间还没获得锁,则返回false
- lock():若有可用锁,则获取该锁并返回,否则会一直等待直到获取可用锁
- lockInterruptibly()能够中断等待获取锁的线程;允许线程在等待时由其它线程调用该等待线程的interupt()方法,中断等待线程,等待线程不用获取锁,而会抛出一个InterruptedException
举例:当两个线程A、B同时通过lock.lockInterruptibly()获取某个锁时,假若此时线程A获取到了锁,而线程B在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程; - 总结:
(1)lock()优先考虑获取锁,待获取锁成功后,才响应中断
(2)lockInterruptibly()优先考虑响应中断
6、synchronized 和 ReentrantLock 的比较
共同点:
1、都用于控制多线程对共享资源的同步访问
2、都是可重入锁
3、都保证了可见性和互斥性,(可见性:一个线程对共享资源的修改,能够及时的被其他线程看到)
不同点:
1、ReentrantLock显式获取和释放锁;synchronized隐式获取和释放锁;在使用ReentrantLock时,为了避免程序中出现异常而无法正常释放锁,必须在finally语句块中执行释放锁操作,而synchronized在正常运行结束或发生异常后都会自动释放锁
2、ReentrantLock可响应中断、可轮询、可定时;提供了更多的灵活性
3、ReentrantLock是API级别的,synchronized是JVM级别的
4、ReentrantLock默认是非公平锁,但可以定义为公平锁,synchronized是非公平锁
5、ReentrantLock通过Condition可以绑定多个条件
6、二者的底层实现不一样:synchronized是同步阻塞,采用的是悲观并发策略;ReentrantLock采用的是同步非阻塞,采用的是乐观并发策略
7、Lock是一个接口;而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现
8、通过 Lock 可以知道有没有成功获取锁,而 synchronized却无法办到
9、Lock 可以提高多个线程进行读操作的效率,也就是实现读写锁等
10、synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally语句块中释放锁,保证锁一定被释放
11、Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断
7、Semaphore
Semaphore是一种基于计数的信号量,在定义信号量对象时可以设定一个阈值,基于该阈值,多个线程竞争获取许可信号,线程竞争到许可信号后开始执行具体的业务逻辑,业务逻辑在执行完成后释放许可信号。在许可信号的竞争队列超过阈值后,新加入的申请许可信号的线程将被阻塞,直到有其他许可信号被释放。Semaphore的基本用法如下:
//Step 1:创建一个计数阈值为5的信号量对象,即只能有5个线程同时访问
Semaphore semp=new Semaphore(5);
try{
//Step 2:申请许可
semp.acquire();
try{
//Step 3:执行业务逻辑
System.out.println("执行操作");
}catch(Exception e){
}finally{
//Step 4:释放许可
semp.release();
}
}catch(InterruptedException e){
}
Semaphore对锁的申请和释放和ReentrantLock类似,通过acquire()和release()方法来获取和释放许可信号资源;acquire()方法默认和lockInterruptibly()方法的效果一样,为可响应中断锁,也就是说在等待许可信号量资源的过程中可以被interrupt()方法中断而取消对许可信号的申请。
Semaphore也实现了可轮询的锁请求、定时锁的功能,以及公平锁与非公平锁的机制
Semaphore的锁释放操作也需要手动执行,因为,为了避免线程因执行异常而无法正常释放锁,释放锁的操作必须在finally代码块中完成
Semaphore也可以用于实现一些对象池、资源池的构建,比如静态全局对象池、数据库连接池等;此外,也可以创建计数为1的Semaphore,将其视作一种互斥锁的机制(也叫二元信号量,表示两种互状态),同一时刻只能有一个线程获得该锁
8、AtomicInteger,原子类,线程同步的一种实现方式
在多线程程序中,诸如++i或i++等运算不具有原子性,因此不是安全的线程操作;可以通过synchronized或ReentrantLock将该操作变为一个原子操作,但是synchronized和ReentrantLock均属于重量级锁,因此JVM为此类原子操作提供了一些原子操作同步类,使得同步操作(线程安全操作)更加方便、高效,例如AtomicInteger
AtomicInteger为Integer类提供了原子操作,常见的原子操作类还有AtomicBoolean、AtomicILong、AtomicReference等,它们的实现原理相同,区别在于运算对象的类型不同
AtomicInteger的性能通常是synchronized 和 ReentrantLock的好几倍;具体的用法如下:
class AtomicIntegerDemo implements Runnable{
//1、定义一个原子操作类
private static AtomicInteger safeCounter = new AtomicInteger(0);
public void run(){
for(int m=0;m<1000000;m++){
//2、对原子操作数执行自增操作
safeCounter.getAndIncrement();
}
}
}
实现原理:AtomicInteger是对Integer类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS技术
9、读写锁:ReadWriteLock
如果系统要求共享数据可以同时支持很多线程并发读,但不能支持很多线程并发写,那么使用读锁能很大程度地提高效率;如果系统要求共享数据在同一时刻只能有一个线程在写,且在写的过程中不能读取该共享数据,则需要使用写锁,以防止其他线程来读或者写。
即读写锁中,读读不互斥、读写互斥、写写互斥
10、CyclicBarrier(循环栅栏)
作用:实现一组任务线程相互等待,当所有线程都到达某个屏障点后再进行后续的操作
工作原理:CyclicBarrier类的内部有一个计数器,每个线程在到达屏障点的时候都会调用await()方法将自己阻塞,此时计数器会减1,当计数器减为0的时候所有因调用await方法而被阻塞的线程将被唤醒
CyclicBarrier cb = new CyclicBarrier(50,()->{
System.out.println("人满,发车");
});
for(int i=0;i<50;i++){
new Thread(()->{
try{
cb.await(); //上车
}catch (InterruptedException | BrokenBarrierException e){
e.printStackTrace();
}
}).start();
}
10、CountDownLatch
作用:使一个线程等待其他线程各自执行完毕后再执行
工作原理:通过一个计数器来实现的,计数器的初始值是线程的数量;每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作
CountDownLatch cd = new CountDownLatch(50);
for(int i=0;i<50;i++){
new Thread(()->{
try{
System.out.println(Thread.currentThread().getName());
cd.countDown();
}catch (Exception e){
e.printStackTrace();}
}).start();
}
try{
System.out.println(Thread.currentThread().getName()+"被阻塞");
cd.await(); // 阻塞当前线程,直到计数器的值为0时,被唤醒
System.out.println(Thread.currentThread().getName()+"开始被执行");
}catch (InterruptedException e){
e.printStackTrace();
}
CountDownLatch和CyclicBarrier区别
1、countDownLatch是一个计数器,线程完成一个记录一个,计数器递减,只能只用一次
2、CyclicBarrier的计数器更像一个阀门,需要所有线程都到达,然后继续执行,计数器递增,提供reset功能,可以多次使用
11、偏向锁、轻量级锁、重量级锁
JDK在1.6版本后,为了减少获取锁和释放锁所带来的性能消耗以及提高性能,引入了轻量级锁和偏向锁
锁的状态有4种:无锁、偏向锁、轻量级锁和重量级锁。随着锁竞争越来越激烈,锁可能从偏向锁升级到轻量级锁,再升级到重量级锁,但是再Java中锁只会单向升级,不会降级
偏向锁用于在多线程环境中,只有单个线程需要获取锁,消除这个线程获取锁时获取与释放开销;当出现另一个线程也需要获取锁资源时,偏向锁升级为轻量级锁
轻量级锁适用于线程交替执行同步代码块的情况,如果同一时刻有多个线程访问同一个锁,则会导致轻量级锁升级为重量级锁
重量级锁是基于操作系统的互斥量(Mutex Lock)而实现的锁,会导致进程在用户态与内核态之间切换,相对开销较大。
12、分段锁
分段锁并非一种实际的锁,而是一种锁设计思想,用于将数据分段并在每个分段上都单独加锁,以提高并发效率
13、如何进行锁优化
1、减少锁持有的时间:只在有线程安全要求的程序上加锁来尽量减少同步代码块对锁的持有时间
2、减小锁粒度:将单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并发度,减少同一个锁上的竞争;在减少锁的竞争后,偏向锁、轻量级锁的使用效率才会提高;减小锁粒度最典型的案例就是ConcurrentHashMap中的分段锁
3、锁分离:根据不同的应用场景将锁的功能进行分离,以应对不同的变化,最常见的锁分离思想就是读写锁(ReadWriteLock),它根据锁的功能将锁分离成读锁和写锁,这样读与读不互斥,读写互斥,写写互斥,既保证了线程的安全性,又提高了性能
操作分离思想可进一步延伸为只要操作互不影响,就可以进一步拆分,比如LinkedBlockingQueue从头部取出数据,从尾部加入数据
4、锁粗化:为了保障性能,会要求尽可能将锁的操作细化以减少线程持有锁的时间,但是如果锁分的太细,有可能导致系统频繁获取锁和释放锁,反而影响性能的提升。在这样的情况下,建议将关联性强的锁操作集中起来处理,以提高系统整体的效率
5、锁消除:检查并消除不必要的锁来提高系统的性能