概述

线程同步

线程同步机制:指一套用于协调线程间数据访问及活动的机制,用来保障线程安全以及实现这些线程的共同目标。包括锁、volatile关键字、final关键字、static关键字以及相关的api,如Object.wait()/Object.notify()等。

线程安全:多线程程序运行的最终结果与预期一致,就是线程安全,否则就是线程不安全。
保证线程安全的条件:

  • 不跨线程访问共享变量,线程共享的变量改为方法局部变量
  • 使状态变量为不可变的,使用final修饰(将变量变为常量)
  • 在任何访问状态变量时需要同步,使用synchronized修饰方法或同步代码块
  • 每个共享的可变变量都需要由一个确定的锁保护,使用Lock锁

锁介绍

锁:是一种保护机制,在多线程条件下,对共享数据访问的许可证

  • 一个线程在访问线程之前必须先申请相应的锁,这个过程称为锁的获得(Acquire)
  • 一个线程获得某个锁,一个锁一次只能被一个线程持有,获得锁的线程称为相应锁的持有线程
  • 锁的持有者可以对数据进行访问,访问完成后必须释放(Release)相应的锁
  • 锁的持有线程获得锁之后和释放锁之前这段时间执行的代码被称为临界区
  • 如果有多个线程访问同一个锁所保护的共享数据,就称这些线程同步在这个锁上;或者称我们对这些线程所进行的共享数据访问进行加锁;相应的,这些线程所执行的临界区被称为这个锁所引导的临界区

锁的适用场景:

  • check-then-act
  • read-modify-write
  • 多个线程对共享资源进行更新

锁的重排序规则:

  • 临界区内、临界区外的代码可以在各自的区域重排序;
  • 临界区外的代码相对于临界区内的代码”许进不许出“;

锁的分类

根据不同角度,可以将锁分为不同的种类,如下图:
Java中锁的分类.png

内部锁和显示锁

根据 java 虚拟机划分:

  • 内部锁(Intrinsic Lock):synchronized关键字实现;
  • 显示锁(Explicit Lock):java.concurrent.locks.Lock接口的实现类实现;

内部锁:Java平台中的任意一个对象都有唯一一个与之关联的锁,这种锁被称为监视器(Monitor)或内部锁(Intrinsic Lock)

  • 内部锁属于排他锁,能够保障三性(原子性、有序性、可见性);
  • 通过 synchronized 关键字实现,该关键字能够修饰方法以及代码块;
  • synchronized修饰的方法被称为同步方法:
    • 修饰的静态方法称为同步静态方法;
    • 修饰的实例方法称为同步实例方法;
  • Java内部锁会为每个内部锁分配一个入口集(Entry Set),用于记录等待获取相应内部锁的线程。入口集中的线程被称为等待线程

    1. // 锁句柄表示一个对象的引用,或者能够返回对象的表达式
    2. // 比如this关键字表示当前对象
    3. // 锁句柄通常用private final修饰
    4. synchronized (锁句柄){
    5. // 在此代码块中访问共享数据
    6. }

    synchronized 的不足:

  • 效率低:锁的释放情况少,试图获得锁的时候不能设定超时,不能中断一个正在试图获取锁的线程

  • 不够灵活:加锁和释放锁的时机单一,每个锁仅有单一的条件(某个对象)
  • 无法知道是否成功获取到锁

显示锁:java.concurrent.locks.Lock 接口的实例为显示锁。该接口对锁进行了抽象,默认实现:java.util.concurrent.locks.ReentrantLock

java.concurrent.locks.Lock 接口的常用方法:

  • void lock():最普通的获取锁,如果锁已经被其他线程获取,则进行等待
    • Lock 不会像 synchronized 一样在异常时自动释放锁
    • lock() 方法不能被中断,因此一旦死锁,就会陷入永久等待
    • 最佳实践:在 finally 中进行锁资源的释放,保证发生异常时锁一定被释放
  • boolean tryLock():用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true,否则返回 false,代表获取锁失败
    • 该方法不管有没有获取到锁,都会立即返回,且可以根据返回值来决定下一步的行为
  • boolean tryLock(long time , TimeUnit unit):超时就放弃
  • void lockInterruptibly():相当于 tryLock(long time,TimeUnit unit) 把超时时间设置为无限,在等待锁的过程中,线程可以被中断
  • Condition newCondition():返回绑定到此 Lock 的新 Condition 实例
  • void unlock():释放锁 ```java // 1.创建一个Lock接口实例 private static final Lock lock=new ReentrantLock();

// 2.申请锁lock lock.lock(); try { // 3.在此对共享数据进行访问 } finally { // 4.总是在finally块中释放锁,以免锁泄露 lock.unlock(); }

// 或者使用 tryLock try { if (lock.tryLock(800, TimeUnit.MILLISECONDS)) { try { // 访问共享资源 } finally { lock.unlock(); } } else { System.out.println(“获取锁失败!”); } } catch (InterruptedException e) { e.printStackTrace(); }

  1. <a name="vIIMj"></a>
  2. #### 乐观锁与悲观锁
  3. 1、悲观锁:对外界的修改持保守态度,在整个数据处理过程中,将数据处于锁定状态,在 java 中,Lock 和 synchronized 就是典型的悲观锁,悲观锁适用于“读少写多,持锁时间长”场景,如以下场景
  4. - 临界区有 IO 操作
  5. - 临界区代码复杂或循环量大
  6. - 临界区竞争激烈
  7. 2、乐观锁:跟悲观锁相反,假设数据一般情况下不会造成冲突,只有在数据进行提交更新时,才会正式对数据的冲突与否进行检测,如果冲突了,则返回错误信息,让用户决定如何去做,一共分为三个阶段:
  8. - 数据读取<br />
  9. - 写入校验<br />
  10. - 数据写入
  11. 乐观锁适用于**读多写少**的场景,可以提高系统的并发量,java中乐观锁的基础 CAS ,典型的例子就是原子类和并发容器
  12. 乐观锁和悲观锁在数据库中的应用:
  13. - select for update:悲观锁
  14. - 用 version 进行版本控制:乐观锁
  15. 用 version 进行版本控制的方式:
  16. 1. 添加一个字段 lock_version
  17. 1. 先查询这个更新语句的 version:select * from table_name
  18. 1. 然后进行更新:update table_name set num=2,version=version+1 where version=1 and id=5;
  19. 1. 如果 version 被更新了等于 2,则更新会出错,这就是乐观锁的基本原理,在更新时才进行判断
  20. 乐观锁与悲观锁的开销对比:
  21. - 悲观锁的原始开销要高于乐观锁,但是优点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
  22. - 乐观锁的最初开销小于悲观锁,但如果自旋时间很长或者不停重试,则会消耗越来越多的资源
  23. <a name="msv7M"></a>
  24. #### 可重入锁和非可重入锁
  25. 可重入锁:也叫递归锁,是指同一个线程在调用外层方法获取锁的时候,再进入内层会自动获取锁, ReentrantLock 和 synchronized 都是可重入锁<br />可重入锁的优点:在一定程度上避免死锁
  26. ReentrantLock 的常用 API:
  27. - lock.getHoldCount:获取锁的进入次数
  28. - lock.isHeldByCurrentThread:锁是否被当前线程持有
  29. - lock.getQueueLength:返回正在等待该锁的队列有多长
  30. ReentrantLock 的示例:
  31. ```java
  32. public class Demo01 {
  33. private static final ReentrantLock LOCK = new ReentrantLock();
  34. public static void main(String[] args) {
  35. LOCK.lock();
  36. System.out.println(LOCK.getHoldCount()); // 1
  37. System.out.println(LOCK.isHeldByCurrentThread()); // true
  38. System.out.println(LOCK.getQueueLength()); // 0
  39. LOCK.lock();
  40. System.out.println(LOCK.getHoldCount()); // 2
  41. LOCK.unlock();
  42. System.out.println(LOCK.getHoldCount()); // 1
  43. LOCK.unlock();
  44. System.out.println(LOCK.getHoldCount()); // 0
  45. }
  46. }

synchronized 示例:该示例中,method1和method2都是同步在 this 示例上,如果 synchronized 不是可重入锁,则 method2 不会被当前线程执行,造成死锁

  1. public class Demo02 {
  2. public static void main(String[] args) throws InterruptedException {
  3. Demo02 demo02 = new Demo02();
  4. demo02.method1();
  5. }
  6. private synchronized void method1() throws InterruptedException {
  7. Thread.sleep(1000);
  8. System.out.println("已经进入方法1,准备进入方法2");
  9. method2();
  10. }
  11. private synchronized void method2() throws InterruptedException {
  12. System.out.println("进入方法2");
  13. Thread.sleep(1000);
  14. }
  15. }

公平与非公平锁

1、公平锁:指多个线程按照申请锁的顺序来获取锁

  • 优势:各个线程公平平等,每个线程在等待一段时间后,总有执行的机会
  • 不足:相较于非公平锁,执行速度更慢,吞吐量更小

2、非公平锁:不完全按照请求的顺序来分配锁,在一定条件下,可以插队

  • 优势:执行速度更快,吞吐量更大
  • 不足:有可能产生线程饥饿或者优先级反转

java 中锁实现:

  • ReentrantLock 和 synchronized 默认非公平锁
  • ReentrantLock 可以通过构造函数来指定该锁是否公平:ReentrantLock(boolean fair)
    • 当传入的参数为 true 时,为公平锁
    • 当传入的参数为 false 时,为非公平锁

独享锁和共享锁

1、独享锁:又叫独占锁、排他锁,指该锁一次只能被一个线程持有,ReentrantLock和synchronized都是独享锁

2、共享锁:该锁可以被多个线程同时持有

ReentrantReadWriteLock:读写锁,其读锁是共享锁,写锁是独享锁,适用于“读多写少”的场景,提高效率

  • 读锁,获得共享锁后,可以查看但无法修改数据,其他线程也可以获取到共享锁,也可以查看但无法修改和删除数据
  • 读写锁使用规则:允许一个或多个线程同时持有读锁或者允许一个线程持有写锁,二者不会同时出现
  • 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享

ReentrantReadWriteLock 实现了 ReadWriteLock 接口,最主要有两个方法:

  • readLock()用来获取读锁
  • writeLock()用来获取写锁
    1. private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    2. private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
    3. private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();

读写锁插队策略:

  • ReentrantReadWriteLock(true):公平锁,不支持插队
  • ReentrantReadWriteLock(false):非公平锁,支持插队
    • 写锁可以随时插队
    • 读锁仅在等待队列头节点不是想获取写锁的线程的时候可以插队

线程策略.png

ReentrantReadWriteLock 中读锁的升级与死锁的降级:

  • ReentrantReadWriteLock 不支持读锁的升级,避免死锁
  • ReentrantReadWriteLock 支持写锁的降级,即支持在获取了读写锁的写锁的情况下,再去获取该锁的读锁
    1. writeLock.lock();
    2. try {
    3. System.out.println("锁降级演示");
    4. readLock.lock(); // 在不释放写锁的情况下获取读锁,成功
    5. System.out.println("获取到读锁");
    6. } finally {
    7. readLock.unlock();
    8. writeLock.unlock();
    9. }

对象锁和类锁

1、对象锁:一个线程可以多次对同一个对象上锁

  • 对于每一个对象,java虚拟机维护一个加锁计数器,线程每获得一次该对象,计数器就加一,每释放一次,计数器就减一,当计数器为0时,锁就被完全释放了
  • 在java程序中,只需要使用 synchronized 块或者 synchronized 方法就可以标志一个监视区域。每当进入一个监视区域时,java虚拟机都会自动锁上对象或类
  • synchronized修饰非静态方法、同步代码块的 synchronized(this)、synchronized(非this对象),锁的是对象,线程想要执行对应的同步代码块,需要获得对象锁

2、类锁:synchronized 修饰的静态方法或者同步代码块的synchronized(类.class),线程想要执行相应的同步代码,需要获得类锁

自旋锁和阻塞锁

自旋锁产生的原因:

  • 阻塞和唤醒一个线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间
  • 如果同步代码块中的内容过于简单,状态切换消耗的时间可能比执行用户代码的时间更长
  • 在很多场景中,同步资源的锁定时间很短,为了这一小段时间切换线程,线程挂起和恢复现场的花费可能让系统得不偿失
  • 如果物理机有多个处理器,能够让两个或以上的线程同时并行执行,这样就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,等待持有锁的线程释放锁,在这个过程中,让请求锁的线程进行自旋,如果自旋完成后持有锁的线程已经释放了锁,那么请求锁的线程就可以不必阻塞而直接获取到锁,避免切换线程的开销,这就是自旋锁

自旋锁:是采用让当前线程不停地在循环体内执行,当循环的条件被其他线程改变时才能进入临界区

  • 自旋锁只是将当前线程不停地执行循环体,不改变线程状态的改变,所以相应速度更快,但当线程数不断增加时,性能下降明显,因为每个线程都需执行,会占用CPU时间片。
  • 如果线程竞争不激烈,并且持有锁的时间短,适合使用自旋锁

**
偏向锁:是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价

轻量级锁(自旋锁):指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能

重量级锁(阻塞锁):是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

阻塞锁:让线程进入阻塞状态进行等待,当获得相应的信号(时间或唤醒)时,才可以进入线程的准备就绪状态

  • 准备就绪的所有线程,通过竞争,进入运行状态
  • 在 java 中,能够进入、退出阻塞状态或者包含阻塞锁的方法有:
    • synchronized关键字,重量锁
    • ReentrantLock
    • Object.wait()/notify()
    • LockSupport.park()/unpack()

自旋锁的实现和原理:

  • 实现:java.util.concurrent.atomic 包下的类基本都是自旋锁的实现
  • 原理:CAS,Compare and Set

自定义自旋锁:

  1. public class TryLockDemo {
  2. public static void main(String[] args) {
  3. TryLockDemo tryLockDemo = new TryLockDemo();
  4. Runnable run = ()->{
  5. System.out.println(Thread.currentThread().getName() + "尝试获取自旋锁");
  6. tryLockDemo.lock();
  7. System.out.println(Thread.currentThread().getName() +"获得自旋锁");
  8. try {
  9. Thread.sleep(200);
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. } finally {
  13. tryLockDemo.unlock();
  14. }
  15. };
  16. new Thread(run,"线程1").start();
  17. new Thread(run,"线程2").start();
  18. }
  19. private AtomicReference<Thread> sign = new AtomicReference<>();
  20. private void lock() {
  21. Thread current = Thread.currentThread();
  22. // 如果sign中的值为null,则没有线程持有,即可顺利获取锁
  23. // sign中的值不为null,则获取锁失败,进行自旋
  24. while (!sign.compareAndSet(null, current)) {
  25. System.out.println(current.getName() + "获取自旋锁失败");
  26. }
  27. }
  28. private void unlock(){
  29. Thread current = Thread.currentThread();
  30. sign.compareAndSet(current, null);
  31. }
  32. }

可中断锁

可中断锁:在 java 中,synchronized 就不是可中断锁,而 Lock 是可中断锁,因为 tryLock(time) 和 lockInterruptibly 都能响应中断

  • 如果某一线程A正在执行锁中的代码,另一线程 B 正在等待获取该锁,可能由于等待时间过长,线程B不想等待了,想先处理其他事情,就可以进行中断,这就是可中断锁

锁优化

  • JVM:自旋锁和自适应、锁消除、锁粗化
  • 个人:
    • 缩小同步代码块
    • 尽量不要锁住方法
    • 减少请求锁的次数
    • 避免人为制造“热点”
    • 锁中尽量不要再包含锁
    • 选择合适的锁类型或合适的工具类