锁策略,即加锁过程(处理冲突时)时的处理方式
乐观锁和悲观锁
乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候不会做太多工作。加锁过程需要做的事情少,则加锁的速度可能更快,但更容易出现一些其他问题(消耗更多的cpu资源)
悲观锁:在加锁之前,预估当前出现锁冲突的概率较大,因此在进行加锁的时候会做更多的工作。加锁过程需要做的事情多,则加锁速度可能更慢,但过程中不容易出现其他问题
重量级锁和轻量级锁
轻量级锁:加锁的开销小,加锁的速度更快(轻量级锁,一般就是乐观锁)
重量级锁:加锁的开销大,加锁的速度更慢(重量级锁,一般就是悲观锁)
轻量还是重量,是加锁之后对结果的评价,而乐观还是悲观是还未加锁前,对锁冲突的预估,但从两种角度都是描述的同一件事情
自旋锁和挂起等待锁
自旋锁:在进行加锁的时候,如果获取锁失败,就立即再尝试获取锁,无限循环,直到获取到锁为止(如:while ( 获取锁 == 失败 ) {})一旦锁被其他线程释放,就能够第一时间获取到锁
自旋锁是一种典型的 轻量级锁 的实现方式,也是一种乐观锁,适用于锁冲突不激烈的情况
优点:不放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能够第一时间获取到锁
缺点:若锁一直不被其他线程释放,则会持续消耗CPU资源
挂起等待锁:在进行加锁的时候,如果获取锁失败,就挂起等待(不消耗CPU资源),而当其他线程释放锁,由系统决定是否其进行加锁
挂起等待锁是一种典型的 重量级锁 的实现方式,也是一种悲观锁,适用于锁冲突激烈的情况
优点:减少了CPU资源的浪费
缺点:锁被释放后,不能第一时间获取到锁,再次加锁时间由系统决定
synchronized是哪一种锁呢?
synchronized具有自适应能力,某些情况下是 乐观锁(轻量级锁/自旋锁),而某些情况下是 悲观锁(重量级锁/挂起等待锁)。synchronized内部会自动评估当前锁冲突的激烈程度,若当前锁冲突激烈程度不大,就是 乐观锁(轻量级锁/自旋锁);而若当前锁冲突较为激烈,则是悲观锁(重量级锁/挂起等待锁)
互斥锁和读写锁
互斥锁:加锁就是单纯的加锁,即资源只能当前被获取锁的线程访问
加锁操作是为了防止 一个线程在进行写操作时,另一个线程进行读取或者写操作
而,若线程都只对其进行读操作,此时不会涉及到线程安全问题,此时加上互斥锁就会影响读取速度,对性能有一定的损失,此时,我们可以使用读写锁解决上述问题
读写锁:
当一个线程加读锁时,另一个线程只能读,不能写,即当前线程正在进行读取操作,其他线程也可以进行读取操作,但是不能写
当一个线程加写锁时,另一个线程不能读,也不能写,即当前线程正在进行写操作,其他线程即不行读也不能写
synchronized属于互斥锁,其操作只涉及到加锁和写锁。在标准库中,提供了专门的读写锁(ReadWriteLock)
公平锁和非公平锁
公平锁:遵循“先来后到”原则,即等待时间长的线程先获取到锁
非公平锁:不遵循“先来后到”原则,线程等概率获取到锁
在操作系统内部的线程调度可视为随机的,即锁是非公平锁,若想实现公平锁,就需要使用额外的数据结构来记录线程之间的先后顺序。synchronized 是非公平锁
可重入锁和不可重入锁
可重入锁:可重新进入的锁,即允许同一个线程多次获得到同一把锁
不可重入锁:不可重新进入的锁,即不允许同一个线程多次获取到同一把锁
例如:一个递归函数中有加锁操作,在递归过程中这个锁会阻塞自己吗?若不会,则是可重入锁;若会,则是不可重入锁(即自己把自己锁死)
在Java中,只要以Reentrant开头命名的锁都是可重⼊锁,⽽且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而Linux系统提供的mutex是不可重入锁。
可重入锁的实现方式是在锁中记录持有该锁的线程身份,以及实现一个计数器(用于记录加锁次数),若当前加锁的线程是当前持有锁的线程,则只需要将计数器次数增加。
synchronized内部的工作原理
结合上面的锁策略我们可以看出sychronized:
1. 具有自适应能力(不同情况下使用不同锁)
2. 不是读写锁,是互斥锁
3. 是不公平锁
4.是可重入锁
synchronized的加锁过程:
JVM将synchronized分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升序
偏向锁状态:
偏向锁并不是真的进行“加锁”,而是只给锁对象做一个“偏向锁标记”,记录这个锁属于哪个线程。若后续没有其他线程来竞争该锁,则不用进行其他操作(这样就避免了加锁和解锁的开销),而若后续有其他的线程来竞争该锁(由于已经对锁对象进行标记,因此很容易识别申请加锁的线程是否是之前记录的线程),则就进入 轻量级锁状态
偏向锁本质上相当于“延迟加锁”,即能不加锁就不加锁,尽量避免不必要的加锁开销
轻量级锁状态:
当其他线程竞争该锁的时候,偏向锁状态解除,进入轻量级锁状态(自适应的自旋锁)
每次加锁都会经历这三个阶段吗?
偏向锁标记,是对象头里的一个标记,每个锁对象都有自己的标记,当这个锁对象首次被加锁时,会先进入偏向锁状态,而在这个过程中,没有涉及锁竞争,则下次加锁时还是进入偏向锁状态;而若在此过程中升级为轻量级锁,后续再针对这个对象加锁,就直接是轻量级锁了(跳过偏向锁)
重量级锁状态:
当锁竞争进一步激烈,自旋不能快速获取到锁状态,则会进入重量级锁状态
此时拿不到锁的线程就不会继续自旋了,而是进入阻塞等待,让出CPU。而当持有锁的线程释放锁时,就由系统随机唤醒一个线程来获取锁。
锁消除
编译器会判断当前锁是否可以消除,若可以消除,就直接将其消除掉
例如:
StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("ab"); stringBuilder.append("bc"); stringBuilder.append("cd"); stringBuilder.append("de");
在上述使用了synchronized,但没有多线程的环境下,若每个append的调用都进行加锁和解锁,则会浪费资源开销,降低效率,因此这些加锁和解锁操作是没有必要的,是可以进行“锁消除”的
锁粗化
若一段逻辑中多次出现加锁和解锁,则编译器会将自动将其合并为异常加锁和解锁,即将多个细粒度的锁合并为一个粗粒度的锁
使用细粒度的锁,是期望释放锁的时候其他线程能够使用锁,但实际可能没有其他线程来抢占这个锁,此时JVM就会自动将锁粗化,避免频繁加锁和解锁
CAS
CAS:compare and swap,即比较并交换,一个CAS涉及以及操作:
设内存中原有数据V,旧的预期值为A,需要修改的新值为B
1. 比较A与V是否相同(比较)
2. 如果比较相等,则将B写入V(交换)
3.返回操作是否成功
例如,我们以下面的伪代码来理解CAS的工作流程:
boolean CAS(address, expectValue, swapValue){ if(&address == expectValue){ &address = swapValue; return true; } return false; }
address:内存地址,expectValue:寄存器中旧的预期值,swapValue:需要修改的新值
比较address内存地址中的值是否与expectValue相同,若相同则将swapValue与address内存中的值交换(也可以理解为“赋值”,我们往往只关注内存中最终的值,寄存器中的值用完就不需要了,),并返回true;若不相同,则不进行交换,并返回false
上述的伪代码,实际上表示的是一条CPU指令,而单个CPU指令,本身就是原子的,因此,使用CAS,不涉及加锁,也不会阻塞,合理使用也能够保证线程安全
CAS本身是CPU指令,操作系统对指令进行了封装,而JVM又对操作系统提供的api进行了一层封装。Java将CAS的api放到了unsafe包(CAS涉及到一些系统底层内容,使用不当可能会带来一定的风险)中。因此,Java标准库又对CAS进行了一层封装,提供了一些工具类
其中最主要的一个工具,叫做原子类
import java.util.concurrent.atomic
例如,AtomicInteger,对Integer进行了封装,当针对Integer对象进行多线程修改时,就是线程安全的
当我们使用AtomicInteger在多线程情况下对Integer对象count进行++操作:
class Demo{ private static AtomicInteger count = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for (int i = 0; i < 5000; i++) { count.getAndIncrement(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 5000; i++) { count.getAndIncrement(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count.get()); } }
其运行结果为10000
而当我们直接使用int类型的变量时:
class Demo1{ private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for (int i = 0; i < 5000; i++) { count++; } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 5000; i++) { count++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
此时的运行结果则是随机的,但基本上是小于10000的
为什么会出现不同的结果呢?
在使用count++时,涉及到三个指令 load add save
因此,虽然执行了两次count++操作,但结果却为1,要想解决上述问题,则需要通过加锁操作,将这三个操作“打包”,即让其具有“原子性”(三个操作连在一起执行,执行过程中其他线程不能调度执行)
而当使用count.getAndIncrement()方法时,
基于上图中的情况,其能够判断在执行++操作前是否有另一个线程修改了count,若count被修改,则重新获取预期值