简介
锁,是并发场景主流的控制手段,不同的场景适用的锁不同,比如有的场景并发率低,适用乐观锁;有的场景并发率高,适用悲观锁。
甚至可以将锁粒度进一步细分,比如读写锁,一般系统大部分的操作是读操作,因此,将读写操作分离,分别控制,将大大提升系统的吞吐率。
锁,本质是要让并发场景下,对同一对象/记录的修改操作排队串行处理,对于性能来讲,这必然是一个瓶颈;因此,锁的发展也是围绕着如何高效的解决这个瓶颈问题,"两害相权取其轻",不同的业务场景,适用的锁也不同,没有一把锁可以在任何场景下都适用!
锁分类
乐观锁和悲观锁
1、悲观锁:
悲观锁(Pessimistic Lock), 每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁。这样其他人想拿数据就被挡住,直到悲观锁被释放。
悲观锁代表有:synchronized
2、乐观锁:
乐观锁(Optimistic Lock), 每次去拿数据的时候都认为别人不会修改。所以不会上锁! 但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。
如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作)。
乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法,Java 原子类中的递增操作就通过 CAS 自旋实现的。
乐观锁代表有:AtomicInteger
CAS: 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁的情况下实现多线程之间的变量同步。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值("比较+更新"整体是一个原子操作),否则不会执行任何操作。一般情况下,"更新"是一个不断重试的操作。
CAS 强调的是一个整体,要么同时成功、要么同时失败。
CAS存在的问题:
- ABA问题。CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面添加版本号,每次更新的时候都把版本号加一。
- 循环时间长开销大。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销,需要控制自旋次数。
- 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证整个操作的原子性。Java 从 1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。
自旋锁 VS 适应性自旋锁
核心考量是:适当自旋的开销 VS 线程上下文切换的开销
1、自旋锁:
阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。
如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程稍等一下,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
通过自旋锁减少 CPU 切换以及恢复现场导致的消耗。
2、自适应自旋锁:
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。
如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
这四种锁是指锁的状态,专门针对 synchronized 的特性。
正确理解对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
对象头由以下三部分组成:
- Mark Word
- 指向类 Class 的指针
- 数组长度(只有数组对象才有)
其中,Mark Word 在 64 位 JVM 的存储:
JVM 是如何使用 Mark Word?
- 当没有被当成锁时,这就是一个普通的对象,Mark Word 记录对象的 HashCode,锁标志位是 01,是否偏向锁那一位是 0。
- 当对象被当做同步锁并有一个线程 A 抢到了锁时,锁标志位还是 01,但是否偏向锁那一位改成 1,前 23bit 记录抢到锁的线程 id,表示进入 偏向锁 状态。
- 当线程 A 再次试图来获得锁时,JVM 发现同步锁对象的标志位是 01,是否偏向锁是 1,也就是偏向状态,Mark Word 中记录的线程 id 就是线程 A 自己的 id,表示线程 A 已经获得了这个偏向锁,可以执行同步锁的代码。
- 当线程 B 试图获得这个锁时,JVM 发现同步锁处于偏向状态,但是 Mark Word 中的线程id 记录的不是 B,那么线程 B 会先用 CAS 操作试图获得锁,这里的获得锁操作是有可能成功的。偏向锁使用了遇到 竞争 才释放锁的机制,偏向锁的撤销需要等待全局安全点,然后它会首先暂停拥有偏向锁的线程,然后判断线程是否还活着,如果线程还活着,则 升级为轻量级锁 ,否则,将锁设置为 无锁状态 。如果抢锁成功,就把 Mark Word 里的线程 id 改为线程 B 的 id,代表线程 B 获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤下一步。
- 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM 会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁 Mark Word 的指针,同时在对象锁 Mark Word 中保存指向这片空间的指针。上述两个保存操作都是 CAS 操作,如果保存成功,代表线程抢到了同步锁,就把 Mark Word 中的锁标志位改成 00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤 6。
- 轻量级锁抢锁失败,JVM 会使用自旋,不断的重试,尝试抢锁。从 JDK1.7 开始,自旋默认启用,自旋次数由 JVM 决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤 7。
- 自旋锁重试抢锁失败 ,同步锁会升级至 重量级锁,锁标志位改为 10。升级到重量级锁在这个状态下,未抢到锁的线程都会被阻塞。
公平锁 VS 非公平锁
在 Java 的 JUC 并发包下有 AQS 的实现,其中就有公平锁和非公平锁的实现。
1、公平锁:
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。
- 公平锁的优点:等待锁的线程不会饿死。
- 缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大。
2、非公平锁:
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
- 非公平锁的优点:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
- 缺点:处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
比如,Java 的 ReentrantLock 在构造时可以指定公平还是非公平锁,默认为非公平锁。
可重入锁 VS 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
Java 中 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
独享锁 VS 共享锁
1、独享锁:
也叫排他锁、独占锁,是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。
获得排它锁的线程既能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。
2、共享锁:
是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
小结
本文主要讲解了 Java 主流锁的特性,每个特性都有其优劣势,因此在当前主流的锁实现中,通常都会组合几个特性进行实现,比如 synchronized 包含特性:
- 悲观锁
- 无锁、偏向锁、轻量级锁、重量级锁
- 非公平锁
- 可重入锁
- 排他锁
当然,融合特性越多,锁越偏重量级,因此有实现特性少的,如 AtomicXxx 系列支持原子类操作,主要采用 CAS 乐观锁去实现原子特性。
在多线程编程中,选择合适的锁机制是非常重要的,因为它直接影响到程序的性能和正确性,可以结合以上特性分析,选择最合适场景的锁进行开发。