线程并发安全
# synchronized关键字的底层原理
synchronized 底层使用的 JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能获得到锁的。
synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
monitor内部维护了三个变量
- WaitSet:保存处于Waiting状态的线程
- EntryList:保存处于Blocked状态的线程
- Owner:持有锁的线程
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。
waitset关联的是处于在执行过程中进入Waiting状态的线程
# synchronized和lock的区别⭐⭐⭐
第一,语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
- Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- Lock 是自旋锁的实现,在竞争激烈时,通常会提供更好的性能
统合来看,需要根据不同的场景来选择不同的锁的使用。
# synchronized锁升级的原理
重量级锁:早期synchronized
直接使用OS的mutex lock
(互斥锁),由于权限隔离的关系,每次加锁/解锁都涉及用户态-内核态切换,性能开销大。
所以JDK1.6版本之后,synchronized增加了锁升级机制,线程访问synchronized同步代码块时,synchronized会根据线程的竞争情况,尝试在不加重量级锁的情况下去保证线程安全性,所以引入了偏向锁和轻量级锁这样一个机制,
偏向锁就是直接把当前的锁偏向于某个线程,简单来说就是通过CAS机制来修改偏向锁的一个标记,这种锁适合同一个线程,多次去申请同一个所资源的情况,并且没有其他线程竞争的一个场景中,
轻量级锁也可以称为自旋锁,它是基于自适应自旋的机制,通过多次自旋去重试竞争锁,自学锁的优点在于,它可以避免了用户态到内核态切换带来的性能损耗,
synchronize引入锁升级这个机制之后,如果有线程去竞争锁, 首先,synchronized会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,那么表示加锁成功,直接返回就好了,如果竞争偏向锁失败,说明当前已经有其他线程占用了偏向锁, 那么就需要将锁升级到轻量级锁,在轻量级锁的状态下,竞争锁的线程会根据自适应自旋次数去尝试,自旋占用锁资源,如果在轻量级锁状态下还是没有竞争到锁, 就会升级到重量级锁,在重量级锁状态下,没有竞争的锁的线程会被阻塞,需要等待获得锁的线程释放锁之后触发唤醒,
总的来说啊,synchronize锁升级的设计思想,本质上是一种性能和安全性的一个平衡,这种思想在编程领域是比较常见的,比如说MYSQL里面的MVCC,使用了版本链的方式,来解决多个并行事务的竞争问题
# JMM(Java 内存模型)
Java内存模型是Java虚拟机规范中定义的一种非常重要的内存模型。它的主要作用是描述Java程序中线程共享变量的访问规则,以及这些变量在JVM中是如何被存储和读取的,涉及到一些底层的细节。
这个模型有几个核心的特点。首先,所有的共享变量,包括实例变量和类变量,都被存储在主内存中,也就是计算机的RAM。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。
其次,每个线程都有自己的工作内存,这里保留了线程所使用的变量的工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。
最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。
# CAS
CAS的全称是: Compare And Swap(比较再交换);
在多线程环境下会存在原子性的问题,需要通过添加Synchronized同步锁来解决,但是加同步锁一定会带来性能上的损耗,而CAS在操作共享变量的时候使用的自旋锁,可以在无锁状态下保证线程操作数据的原子性,效率上更高一些,它体现的一种乐观锁的思想。
CAS的底层是调用的Unsafe类中的方法,这个方法提供了四个参数,分别是当前对象,实例成员变量再内存地址中的偏移量,预期值和期望更改值,CAS回去比较当前对象在内存地址偏移后的变量的值是否与预期值相等,相等就修改为期望更改值
CAS不管在什么层面去实现啊,都会存在原则性问题,所以CAS的底层实现,如果是在多核的CPU环境下,会增加一个lock指令来对缓存或者总线去加锁,从而去保证啊比较并替换这两个操作的原子性,
CAS使用到的地方很多:AQS框架、AtomicXXX类
# 乐观锁和悲观锁的区别
乐观锁(如CAS):最乐观的估计,总是假设没有别的线程来修改共享变量,所以不上锁,在最后才查看共享变量有没有被修改,就算改了就通过自旋重试。
悲观锁(如synchronized):最悲观的估计,总是上锁防着其他线程来修改共享变量,改完了解开锁,其他线程才有机会修改。
# 请谈谈你对 volatile 的理解
volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
# 什么是AQS?
AQS就是AbstractQueuedSynchronizer,是java并发包中的一个核心框架,为锁和同步器提供了底层实现,比如:Lock、CountDownLatch、Semaphore
AQS内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过CAS操作来原子性地更新状态值
在它的内部还提供了基于 FIFO 的双向链表结构的等待队列
AQS定义了获取和释放同步状态的模板方法,具体实现由子类完成(如ReentrantLock、CountDownLatch等)
其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。
# AQS为什么使用双向链表
双向链表提供了双向指针,可以在任何一个节点,方便向前或者向后进行便利,这种对于有双向遍历需求的场景来说非常有用,
双向链表可以在任意一个节点的位置,实现数据的插入和删除,并且这些操作的时间复杂度是常量,复杂度不受链表长度的影响,这对于需要频繁对链表进行增删操作的场景,非常有用,
AQS采用双向链表的原因
存储在双向链表的线程可能因中断或超时失效,需从链表中移除。而删除操作需要找到这个节点的前驱节点,双向链表可以在O(1)时间找到前驱节点并删除,而单向链表需从头遍历O(n)。
线程阻塞,加入到链表的线程前,需判断前驱节点的waitStatus
是否为SIGNAL
,双向链表可直接访问前驱节点,避免遍历。
线程在抢锁时需自旋确认自身节点是否为头节点的后继(即下一个候选者),而使用双向链表能快速定位前驱(头节点)。
所以AQS存在很多需要双向遍历的场景,来提升现成的阻塞和唤醒的效率
# 什么是可重入锁
可重入锁(Reentrant Lock)是指同一线程在持有锁后,可以重复获取同一把锁而不会阻塞,仅通过内部计数器记录重入次数。
在java中绝大部分的锁都是可重入的,比如说Synchronized以及Reentrant lock,但是也有一些不支持重写的锁,比如说JDK8里面提供的读写锁,StampedLock,
锁的可重入性主要是为了避免死锁问题,若锁不可重入,线程在嵌套调用中尝试重复获取锁时(如递归或调用其他同步方法),会因“自己等待自己释放锁”导致死锁
# ReentrantLock的实现原理
ReentrantLock是属于juc包下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。
它的底层实现原理主要利用CAS+AQS队列来实现,通过CAS修改state变量来表示是否获取锁。
ReentrantLock是一个可重入锁,调用 lock() 方法获取了锁之后,再次调用 lock(),是不会再竞争一次锁,内部直接增加重入次数就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。
提供了阻塞竞争锁和非阻塞竞争锁两种方法,分别是lock()和tryLock()方法 未竞争到锁的线程,存储在AbstractQueueSynchronizer队列同步器中,底层是通过双向链表来实现,当锁被释放之后呢,会从AQS队列里面的头部,去唤醒下一个等待线程
支持公平和非公平 构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。主要呢是体现在竞争的时候,是否需要判断AQS队列里面,是否有等待的线程,而非公平锁是不需要去判断的,
# 死锁
死锁就是两个或者两个以上的线程同时争夺同一个资源造成互相等待对方资源释放的情况,
导致死锁的条件有四个
- 互斥条件:共享资源只能被一个线程占有
- 请求和保持条件:线程取得共享资源而等待其他共享资源时,不释放原来的共享资源
- 不可抢占条件:线程占用的资源不能被其他线程强行抢占
- 循环等待条件:线程互相等待对方的资源
破坏死锁的方法
- 互斥条件是没有办法破坏的,他是互斥锁的基本约束
- 请求保持条件可以通过一次申请所有资源来破坏
- 不可抢占条件可以通过线程申请不到其他资源时主动释放原有的资源来破坏
- 循环等待条件可以通过给资源排序,线程申请资源时按序申请来破坏
# 死锁产生的条件是什么
一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:
t1 线程获得A对象锁,接下来想获取B对象的锁
t2 线程获得B对象锁,接下来想获取A对象的锁
这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁
# 如何进行死锁诊断?
我们只需要通过jdk自动的工具就能搞定
我们可以先通过jps来查看当前java程序运行的进程id
然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
# ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。
JDK1.7的底层采用是分段的数组+链表 实现
- 在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。默认有16个Segment,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
- 在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发 , 效率得到提升,引入红黑树这样一个机制,去降低了数据查询的时间复杂度,红黑素的时间复杂度是O log n,
- 初始创建长度为16的Node数组
- 哈希冲突时会形成链表
- 当链表长度≥8且数组长度≥64时转为红黑树(O(log n))
- 当树节点数≤6时退化为链表
当数组的长度不够的时候,ConcurrentHashMap 需要对数组进行扩容,而在扩容的时间上,ConcurrentHashMap 引入了多线程并发扩容的一个实现,简单来说呢就是多个线程对原始数组进行分片,分片之后,每个线程去负责一个分片的数据迁移,从而去整体的提升了扩容过程中的数据迁移的效率,
在多线程并发场景中啊,在保证原子性的前提下去实现元素个数的累加,性能是非常低的,所以ConcurrentHashMap,做了两点的优化, 当线程竞争不激烈的时候,直接采用cas的方式,来实现元素个数的一个原子递增, 如果线程竞争比较激烈的情况下,使用一个数组来维护元素个数,如果要增加总的元素个数的时候,直接从数组中随机选择一个,再通过CAS算法来实现原子递增
# ConcurrentHashMap的size()方法是不是线程安全的
ConcurrentHashMap的size()方法实现本身是线程安全的,但在实际使用时,当有线程执行put()操作添加元素时,其他线程调用size()获取的数值可能与实际存储的元素个数不一致。这种不一致性源于size()方法没有加同步锁,put()和size()方法之间缺乏同步机制。
ConcurrentHashMap对数组元素的累加采用两个方案:
- 低竞争场景:使用CAS操作对基础计数器进行原子递增
- 高竞争场景:采用CounterCell数组,通过分治思想减少线程竞争
size()方法的计算逻辑是遍历所有CounterCell的值进行累加,再加上基础计数器的值。这个计算过程虽然是线程安全的,因为使用CAS保证原子性,但由于计算期间可能正好有一个元素添加到这个数组里面,没有统计上去,导致最后的计算结果不一致。
之所以不采用HashTable那样的全局锁方案,主要基于两点考虑:
- 直接在size方法上去加锁,就会造成数据写入的一个并发冲突,对于性能的影响会比较大,
- 设计理念:ConcurrentHashMap更注重数据存储安全,对于size的数据一致性要求并不高
# 为什么ConcurrentHashMap中key不允许为null
ConcurrentHashMap 的设计禁止 key
为 null
,主要是为了避免多线程并发场景下的歧义问题。
当线程从 ConcurrentHashMap 中通过 get(key)
获取数据时,如果返回值为 null
,线程无法明确区分以下两种情况:
- Key 不存在:Map 中未存储该键。
- Key 存在但 value 为
null
:键对应的值本身就是null
。
这种二义性会导致线程安全性问题(例如误判键不存在而触发重复插入)。而 ConcurrentHashMap 作为线程安全的集合,必须消除此类不确定性,因此直接禁止 key
为 null
,从设计源头保证逻辑的一致性。
# 导致并发程序出现问题的根本原因是什么
Java并发编程有三大核心特性,分别是原子性、可见性和有序性。
首先,原子性指的是一个线程在CPU中的操作是不可暂停也不可中断的,要么执行完成,要么不执行。比如,一些简单的操作如赋值可能是原子的,但复合操作如自增就不是原子的。为了保证原子性,我们可以使用synchronized关键字或JUC里面的Lock来进行加锁。
其次,可见性是指让一个线程对共享变量的修改对另一个线程可见。由于线程可能在自己的工作内存中缓存共享变量的副本,因此一个线程对共享变量的修改可能不会立即反映在其他线程的工作内存中。为了解决这个问题,我们可以使用synchronized关键字、volatile关键字或Lock来确保可见性。
最后,有序性是指处理器为了提高程序运行效率,可能会对输入代码进行优化,导致程序中各个语句的执行先后顺序与代码中的顺序不一致。虽然处理器会保证程序最终执行结果与代码顺序执行的结果一致,但在某些情况下我们可能需要确保特定的执行顺序。为了解决这个问题,我们可以使用volatile关键字来禁止指令重排。