截屏2020-02-04下午3.04.14.png

Synchronized

能够保证一个同一时刻只有一个线程执行该段代码,以达到保证并发安全的效果

原理

加锁和释放锁

进入同步代码块加锁对应字节码指令monitorenter,释放锁对应字节码指令monitoreexit;在执行时会将存储在对象头中的对象锁计数+一或-一
获取锁时,数据是从主内存中读取;释放锁时,会将数据回写到主内存

Lock接口

简介、地位、作用

锁是一种工具,用于控制对共享资源的访问
Lock和synchronized是两个最常见的锁,他们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同
Lock并不是用来替代synchronized的,而是当synchronized不合适或不足以满足要求的时候,来提供高级功能的

为什么synchronized不够用?为什么需要Lock?

  1. 效率低:锁的释放情况少、试图获取锁时不能设置超时、不能中断一个正在试图获取锁的线程
  2. 不够灵活(读写锁更灵活):加锁和释放的时机单一,每个锁仅有单一的条件(某个对象),可能不够用
  3. 无法知道是否成功获取到锁

方法介绍

在lock中声明了4个方法来获取锁

lock()

就是普通的获取锁,如果已被其他线程获取则等待
lock不会像synchronized一样在异常时自动释放锁
最佳实践,在finally中释放锁,以保证异常发生时,锁一定被释放
lock方法不能被中断,这带来很大隐患:一旦陷入死锁,lock就会陷入永久等待

trylock()

用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回true,否则返回false,代表获取锁失败
我们可以根据是否获取到锁,来决定后续程序的行为
该方法会立即返回,即使在拿不到锁时不会一直在那里等

trylock(long time,TimeUnit unit)

超时就放弃

lockInterruptibly()

相当于trylock把超时时间设置为无限。在等锁的过程中线程可以被中断

可见性保证

可见性
happens-before
lock的加解锁和synchronized有同样的内存语义,也就是说,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作

乐观锁和悲观锁

为什么会诞生非互斥同步锁

互斥同步锁劣势

阻塞和唤醒带来的性能劣势
永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲观线程,将永远得不到执行
优先级反转

悲观锁

  • 如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,每次获取和修改数据时,把数据锁住让别人无法访问该数据,这样就可以确保数据内容万无一失
  • java悲观锁的实现synchronized和lock相关类

    乐观锁

  • 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作的对象

  • 在更新的时候,去对比在我修改期间数据有没有被其他人修改过,如果没被修改过,就说名真的只有我自己在操作,那我就正常的去修改数据
  • 如果数据和我一开始拿到的不一样了,说明其他人在这段时间内改过数据,那我就不能继续刚才的更新数据过程了,我就选择放弃、报错、重试等策略
  • 乐观锁的实现一般都是利用CAS算法实现
  • 乐观锁的典型例子就是原子类、并发容器等

开销对比

悲观锁的原始开销要高于乐观锁,但特点是一劳永逸
乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多

两种锁各自的使用场景

悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况

  1. 临界区有IO操作
  2. 临界区代码复杂或者循环量大
  3. 临界区竞争非常激烈

乐观锁:适用于写入少,大部分是读取的场景,不加锁能让读取的性能大幅提高

可重入锁和非可重入锁

可以多次获取该锁,只把锁的数量+1

好处

可以避免死锁
提升封装性

公平锁和非公平锁

什么是公平和非公平

  • 公平是指按照线程请求的顺序,来分配锁;而非公平指的是,不完全按照请求的顺序,在一定的情况下,可以插队
  • 注意:非公平也不提倡“插队”行为,这里的非公平指的是“在合适的时机”插队,而不是盲目的插队

    为什么要有非公平锁

  • Java设计者是为了提高效率

  • 避免线程唤醒带来的空档期
  • 例子:按照公平应该唤醒线程A,给它分配锁,但是唤醒锁是需要时间的,当这时候正在运行的线程B,来获取锁,线程B就会获取到锁

    不公平的情况(以ReentrantLock为例)

  • 如果线程1在释放锁时,线程5恰好去执行lock()

  • 由于ReentrantLock发现此时并没有线程持有lock这把锁(线程2还未来得及获取到,因为获取需要时间)
  • 线程5可以插队,直接拿到这把锁,这就是ReentrantLock默认的公平策略,也就是“不公平”

截屏2020-02-04下午4.33.31.png

特例

  • 针对tryLock()方法,它是很猛的,它不遵守设计的公平规则
  • 例如:当有线程执行tryLock()的时候,一旦有线程释放了锁,那么这个正在tryLock的线程就能获取到锁,即使它之前已经有其他现在在等待队列里了

优缺点

截屏2020-02-04下午4.48.28.png

共享锁和排它锁

什么是共享锁和排它锁

排它锁,又称为独占锁、独享锁
共享锁,又称为读锁,获得共享锁之后,可以查看但是无法修改和删除数据,其他线程也可以获得该锁
共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是独享锁

读写锁的作用

  • 在没有读写锁之前,我们假设使用ReentrantLock,那么我们虽然保证了线程安全,但是也浪费了一定资源:多个读操作同时进行,并没有线程安全问题
  • 在读的地方用读锁,写的地方用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率

    读写锁的规则

  • 多个线程申请读锁,都可以申请到

  • 如果有一个线程占用了读锁,其他线程如果想申请写锁,需要等待读锁释放
  • 如果有一个线程占用了写锁,此时其他线程申请写锁、读锁,则申请的线程会一直等待写锁释放
  • 一句话总结:要么是一个线程或多个线程有读锁,要么是一个线程有写锁,但是两者不会同时出现(要么多读,要么一写)

换一种思路
读写锁只是一把锁,可以通过两种方式锁定,读锁定和写锁定。读写锁可以同时被一个或多个线程读锁定,也可以被单一线程写锁定。但是永远不能同时对这把锁读锁定和写锁定

demo

  1. public class CinemaReadWrite {
  2. private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
  3. private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
  4. private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
  5. private static void read() {
  6. readLock.lock();
  7. try {
  8. System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
  9. Thread.sleep(1000);
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. } finally {
  13. System.out.println(Thread.currentThread().getName() + "释放读锁");
  14. readLock.unlock();
  15. }
  16. }
  17. private static void write() {
  18. writeLock.lock();
  19. try {
  20. System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
  21. Thread.sleep(1000);
  22. } catch (InterruptedException e) {
  23. e.printStackTrace();
  24. } finally {
  25. System.out.println(Thread.currentThread().getName() + "释放写锁");
  26. writeLock.unlock();
  27. }
  28. }
  29. public static void main(String[] args) {
  30. new Thread(()->read(),"Thread1").start();
  31. new Thread(()->read(),"Thread2").start();
  32. new Thread(()->write(),"Thread3").start();
  33. new Thread(()->write(),"Thread4").start();
  34. }
  35. }

读锁插队策略

ReentrantReadWriteLock采用策略2,不允许读任务插队,损失了一定效率,但避免了写任务的饥饿
截屏2020-02-04下午5.43.45.png

截屏2020-02-04下午5.44.46.png

  • 公平锁:不允许插队
  • 非公平锁:

1)写锁可以随时插队,但是插队会失败
2)读锁仅在等待队列头是读锁时可以插队,等待队列头是写锁时不允许插队

锁的升降级

目的:为了提高效率,当你释放写锁后,再去获取读锁,不知道什么时候才能拿到读锁,如果在写锁中去降级为读锁,可以提高执行效率
只能降级,不能升级
写锁过程中可以降级为读锁
读锁过程中不能升级为写锁,是为了防止死锁

自旋锁和阻塞锁

阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间
如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比代码执行的时间还要长
在许多场景中,同步资源锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就释放锁
而为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果自旋完成后前面锁定同步资源的线程已经放弃了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁
阻塞锁和自旋锁相反,阻塞锁如果没有拿到锁的情况下,会直接把线程阻塞,知道被唤醒

自旋锁的缺点

如果锁被占用的时间很长,那么自旋线程只会白白浪费CPU资源
在自旋过程中,一直消耗CPU,所以虽然自旋锁的起始开销低于悲观锁,但是随着自旋时间的增长,开销也是线性增加的

原理和源码分析

在java1.5版本及以上的并发框架java.util.concurrent的atmoic包下的类基本都是自旋的实现
AtomicInteger的实现:自旋的实现原理是CAS
AtomicInteger中调用unsafe进行自增操作的源码中的do_while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没修改成功,就在while里死循环,直到修改成功

自旋锁适用场景

自旋锁一般用于多核服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
自旋锁用于临界区比较小的情况下,否则如果临界去很大(线程一旦拿到锁,很久以下才会释放),那也是不合适的

可中断锁

在java中synchronized是不可中断锁,lock是可中断锁,因为tryLock(time)和lockInterruptibly都能响应中断
如果某一个线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,我们可以中断它,这种就是可中断锁

锁优化

Java虚拟机对锁的优化

自旋锁和自适应
锁消除
锁粗化

代码优化

  1. 缩小同步代码块
  2. 尽量不要锁住方法
  3. 减少锁的次数
  4. 避免人为制造“热点”
  5. 锁中尽量不要包含锁
  6. 选则合适的锁类型和工具类