Java并发
# Java并发
# 1. 并行和并发有什么区别?
- 并行:同一时刻,同时进行
- 并发:不同的任务交替执行,每个任务执行时间短,宏观上近似于同一时刻同时进行,微观上只有一个任务在执行。
# 2. 线程和进程的区别?
进程:
- 程序的
一次执行过程
通常叫做进程,属于一个动态的概念。 - 进程是一个程序机器数据
在处理机上顺序执行时
所发生的的活动。 - 进程是具有独立工程的程序在一个数据集合上运行的过程,是
系统进行资源分配和调度的一个独立单位
。
- 程序的
线程: 试图用它了的提高程序并发执行的程度。
- 线程问世后成了CPU调度和分派的基本单位,而不再是进程
- 线程是进行的一个实体,不可脱离进程存在;一个进程可以有多个线程
- 线程是进程的一个执行路径
- 每个线程拥有自己的栈资源,用于存储该线程的局部变量表和调用栈帧
区别:
- 本质:
- 进程是操作系统资源分配的基本单位;
- 线程是任务调度和执行的基本单位
- 内存分配方面:
- 系统会为每个进程分配不同的内存空间;
- 但系统不会为线程分配内存,线程使用的资源全部来自于进程
- 资源:
- 进程之间的资源是独立的,进程间不能共享资源;
- 但同一进程的所有线程可以共享该进程的资源(内存、CPU、IO)
- 通信:
- 进程间以IPC(管道、信号量、共享内存、消息队列、文件、套接字)等方式通信;
- 同一进程下,线程之间可以共享全局变量、静态变量等数据进行通信、做到同步和互斥、一次来保证数据的一致性
参考文章:
- https://www.javanav.com/interview/c1c5c5964574489c8d010c3e1a6f3362.html
- 本质:
# 3. 守护线程是什么?
Java 中提供了两种线程: 守护线程和用户线程。
- 守护线程:
- 指在程序运行时在后台提供一种通用的线程,这类线程不属于程序不可或缺的部分。
- 注意:如果用户线程已经全部退出了运行,只剩下守护线程存在,JVM也会跟着退出
- 在Java中,如果在一个守护线程中产生了其他线程,这些新产生的线程默认还是守护线程
- 如
垃圾回收器
就是一个守护线程
- 如
# 4. 创建线程的几种方式?
- 继承Thread
- 实现Runnable接口
- 通过FutureTask 和 Callable接口创建
- FutureTask实现了了RunnableFuture;FutureTask可以有返回值
- 通过线程池的方式创建
# 5. Runnable 和 Callable 有什么区别?
- Runnable接口的run方法没有返回值;Callable接口的call方法有返回值,并且还支持泛型
- Runnable接口的run方法只能抛出运行时异常,而且无法捕获该异常;Callable接口的call方法允许抛出异常,并且可以获取异常信息
# 6. 线程状态及转换?
线程有六种状态:
New (新创建)
- 一个线程被创建了但是没有启动(使用 new Thread 创建一个线程但不调用start()方法)
Runnable (可运行)
- 可运行是可能
正在执行
也可能是该线程的CPU时间片用完了在等待CPU的调度
- 注意:这里必须是没有阻塞地等待CPU的调度
- 可运行是可能
Blocked (被阻塞)
- 当线程需要进入临界区时,没有获取到相应的monitor锁会进行阻塞等待,成功获取锁后会继续执行临界区的代码,状态由 Blocked 变为 Runnable
Waiting (等待):处于这一状态有三种情况
- 调用了
Object.wait()
方法, - 调用了
Thread.join()
方法, - 调用了
LockSupport.park()
方法。 - 注意:这里特别说明
LockSupport.park()
方法,在Blocked状态中、线程是阻塞获取synchronized的monitor监视器锁,,但在java中存在很多其他的显示类型锁
,比如ReentrantLock
等,在这些锁中,如果没有获取到锁则会进入WAITING
状态
- 调用了
BLOCKED状态和WAITING状态的区别
- BLOCKED状态等待其他线程释放monitor锁
- 而WAITING则是可以
等待某些条件
,例如join的线程执行结束,或者其他线程调用了notify
或者notiifyAll
方法
Timed Waiting (超时等待)
TIMED_WAITING状态在WAITING状态的基础上
多了时间的限制
,TIMED_WAITING会在设置的超时时间后自动由系统唤醒,或者提前由以下方法唤醒
Thread.sleep()
Object.wait()
Thread.join()
LockSupport.parkNanos()
LockSupport.parkUntil()
Terminated (被终止)
- 终止状态是在线程的run方法
执行完毕
或者在执行run方法过程中出现了没有处理的异常
,导致线程异常终止线程的状态就会切换到TERMINATED
- 终止状态是在线程的run方法
线程的几种状态之间的转换
Blocked 进入 Runnable
- 该状态下的线程只要
获取了monitor锁
就能转换到Runnable状态 (总之就是要获取锁才能访问临界区的资源)
- 该状态下的线程只要
Waiting 进入Runnable
- 该状态下的线程只有在
join的线程执行结束
或者被中断
后,例如,执行了LockSuport.unpark()
方法后,再进入Runnable 状态 - 注意:其他线程执行了
notify
或者notifyAll
方法会离开Waiting
状态,但转而进入的是Blocked状态
,因为执行这两个房娃的前提是获取到了monitor锁
,也即是说wait()、notify()、notifyAll()
方法 必须在synchronized
同步代码块中执行
- 该状态下的线程只有在
TIMEDWAITING进入RUNNABLE
- 除了超时机制,TIMED_WAITING状态和WAITING状态一样,某个线程在
执行了notify
或者notifyAll
方法后,也会先进入BLOCKED
状态,在获取到monitor后才会进入RUNNABLE
- 加入超时机制后,超时时间到后系统
会自动拿到锁
而不用等待被唤醒,转而直接进入RUNNABLE
状态,其他的进入RUNNABLE状态的方法和WAITING一致
- 除了超时机制,TIMED_WAITING状态和WAITING状态一样,某个线程在
参考图
# 7. sleep() 和 wait() 的区别?
- 来源:
- sleep() 来自Thread类
- wait() 来自Object 类
sleep()
方法调用后,虽然会暂停,但监控依然保持,即不会释放对象锁
;而wait()
方法调用时会放弃对象的锁,进入一个等待的队列
- wait、notify、notifyAll只能在同步代码块或者同步方法中使用,因为要获取对象的锁;而sleep()不用获取对象的锁
- sleep()方法必须捕获中断异常
InterruptedException
而wait、notify、notifyAll不需要 - 注意:
- sleep() 方法只是让出了CPU,不会释放同步资源的锁
- sleep() 发生了异常也不会释放对象的锁、而wait()调用时便放弃了对象锁,就算出异常了也不会占用锁资源。
# 8. 线程的 run() 和 start() 有什么区别?
- run只是调用了里面的方法,而不会启动一个新的线程
- start() 方法会调用操作系统的创建线程的方法来新创建一个线程,所以在创建新的执行线程时应该调用start() 方法而不是 run() 方法
# 9. 在 Java 程序中怎么保证多线程的运行安全?
使用同步机制
# 10. Java 线程同步的几种方法?
- 使用synchronized同步代码块
- 使用JUC中的并发工具 ( ReentrantLock)
- 使用wait()、notify()、notifyAll()
# 11. Thread.interrupt() 方法的工作原理
- 该方法的作用是给线程设置中断标志位,线程会不断检测线程的中断标志位,
- 除此之外,
isInterrupted()
方法是Thread类的一个成员方法,该方法用于检测线程是否被中断了但不会修改当前线程的中断状态 interrupted
是Thread
的一个static
方法,该方法在判断当前线程是否被中断后会清除当前线程的中断状态,重置为false
isInterrupt()
和interrupted()
方法都是调用的一个名叫isInterrupt(boolean ClearInterrupted)
的本地方法
# 12. 谈谈对 ThreadLocal 的理解?
- ThreadLocal的作用
- ThreadLocal是线程的本地变量,如果定义了一个ThreadLocal,则每个线程王这个ThreadLocal中进行的读写操作是线程隔离的;线程之间不会有影响。
- 它为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。
# 13. 在哪些场景下会使用到 ThreadLocal?
- 使用Spring的aop进行权限和是否登录校验的时候,可以将第一次查询的结果缓存到ThreadLocal中,方便后续的业务使用
# 14. 说一说自己对于 synchronized 关键字的了解?
- 用于线程同步
- 可以锁当前对象
- 可以锁当前类
- 可以锁其他的对象
# 15. 如何在项目中使用 synchronized 的?
暂未使用
# 16. 说说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?
JDK1.6 之前的synchronized锁一直都是重量级锁,在JDK 1.6 的时候,JDK开发人员发现了大多数情况下并不存在多个线程的竞争关系,于是,为了而提高系统的并发性能,便引入了一系列的锁:
- 偏向锁
- 轻量级锁
- 重量级锁
先了解 Mark Word
参考博客
- Java的实例对象,包含了三个部分的数据
8个字节
的对象头
,这里保存了该对象实例的信息,如hashcode
,锁
的类型
,以及类型指针
(一个类的元数据是存放在JVM虚拟机的方法区
中,这部分空间也被称作元空间
,而类型指针存放的就是这个类的元数据的地址
- 若干字节的
实例数据
,即保存的一些实例变量,大小是这些实例变量的字节总和 - 若干字节的
对象填充
,这部分的空间没有别的用处,只是为了能让JVM更好地进行内存管理,让整个对象的字节数满足是8的倍数
,如果不满足则会进行填充
- 下图中说明了对象在
不同的锁的状态下
,对象头中存放的信息的差别,这里是64位
的JVM
- Java的实例对象,包含了三个部分的数据
偏向锁:
- 当一个对象的锁状态是偏向锁时,对象头中保存的信息为:
前54个bit
保存正在执行同步代码块的线程的id
,然后2bit
用来保存Epoch
(本质上是一个时间戳,代表偏向锁的有效性);1bit
用来占位
没有实际用处,4bit
用来保存对象的分代年龄
,1bit
用来存放偏向锁的标志
,2bit
用来存放锁的标志位
- 同时在JDK 1.6 中是默认开启偏向锁的,但偏向锁的启动会有延迟,可以使用JVM的启动参数来关闭这一延迟
- 当对象处于
偏向锁
状态时,对象头中的偏向锁标志位是1
,而2bit的锁的标志位是0 1
,即后三个字节的标识bit值为1 0 1
;- 注意: 在JDK1.6之后,系统会默认开启偏向锁,在使用上面的JVM参数后,一开始进入main方法,此时的person的锁的状态码时101,不过这个时候的person对象是可偏向,而不是已经处于偏向状态;这时候,子线程在执行synchronized同步代码块时,会先检测可偏向状态和当前的ThreadID,如果ThreadID为空则会将当前线程的ID写入到ThreadID中去,覆盖原来的hashCode存值的地方,这个过程是用CAS命令更新对象的Mark Word。
关于hashcode的隐藏知识
深入理解JVM虚拟机上面是这样说的:hashCode在第一次被计算后,就不允许改变了,也就是说,如果在新建person对象后,调用person对象的hashcode方法,然后再开一个子线程使用synchronized同步代码块则不会是偏向锁了而是轻量级锁。这是JVM在默认开启偏向锁且延迟为零的情况下的变化 - 在第一次加载后,若该线程依然需要继续执行,则会重入同步代码块,依然会先判断是否是 1 0 1,则会继续比较Thread ID,不需要再执行CAS命令
- 此时,若有第二个线程在竞争person对象的锁,先判断对象中存储的线程id是不是当前线程,若不是则尝试用CAS操作替换;若替换失败,在等到系统达到全局安全点后,会撤销person对象的偏向锁,并暂停原来持有偏向锁的线程;
与此同时,会检测原理的持有偏向锁的线程的执行状态
- 未处于RUNNABLE状态:则会直接将对象成设置成无锁状态
处于RUNNABLE状态:
则会执行批量重偏向
的操作。记录代码执行的位置,在将person对象的锁升级为轻量级锁后,原来持有锁的线程会在线程栈中分配锁记录
,并拷贝person对象头中的Mark Word到锁记录中
,此时如果检测到原有的线程正在运行,则会直接升级为轻量级锁
,那么在比对Thread ID
不同后,就会将person对象的锁升级为轻量级锁,此时锁的标志位变为0 0 0
,在这个线程执行完毕
后会将person对象的锁的状态置为0 0 1
,的无锁状态
,如果后面有线程继续执行同步代码块中的代码时,就会使用CAS自旋尝试获取锁
,
- 此时,若有第二个线程在竞争person对象的锁,先判断对象中存储的线程id是不是当前线程,若不是则尝试用CAS操作替换;若替换失败,在等到系统达到全局安全点后,会撤销person对象的偏向锁,并暂停原来持有偏向锁的线程;
- 注意: 在JDK1.6之后,系统会默认开启偏向锁,在使用上面的JVM参数后,一开始进入main方法,此时的person的锁的状态码时101,不过这个时候的person对象是可偏向,而不是已经处于偏向状态;这时候,子线程在执行synchronized同步代码块时,会先检测可偏向状态和当前的ThreadID,如果ThreadID为空则会将当前线程的ID写入到ThreadID中去,覆盖原来的hashCode存值的地方,这个过程是用CAS命令更新对象的Mark Word。
- 当一个对象的锁状态是偏向锁时,对象头中保存的信息为:
轻量级锁:
- 轻量级锁的对象头结构如上图所示
轻量级锁执行的操作
:- 如果现在的person对象
处于未加锁的状态
,会通过CAS操作来拷贝person对象的对象头到线程栈的Lock Record
区域中的displaced hdr,然后将Lock Record区域中的owner指针
指向person对象的Mark Word 并将Mark Word的对象头的栈中锁记录
的指针指向当前线程的Lock Record
区域成功:
如果这一操作成功,则将当前对象头的锁标记标记为0 0
,表示处于轻量级锁状态
- 失败:如果这一操作更新失败,JVM会首先检测person对象头中的Mark Word
是否指向
当前线程的Lock Record。 是:
如果是则表示当前处于锁重入的状态,会将Lock Record中的displaced hdr置为null,替换原来存储的Mark Word对象头,这一操作主要是记录锁重入的次数
。- 否:如果不是则说明person对象锁已经被其他线程占用了,当前线程会通过
CAS自旋来等待锁
,如果在一定的CAS操作后依然不能获取到对象锁
,就会将person对象的锁状态置为重量级锁
,
- 如果现在的person对象
- 轻量级锁在释放的过程中依然会使用CAS自旋操作将person对象的锁标记替换为
0 1
(无锁状态)
重量级锁:
轻量级锁中说到了如果第二个线程CAS操作超过一定的次数仍然不能获取到person对象的锁时,就会将person对象的锁膨胀为重量级锁,这个时候第二个线程先将锁的标志置为10(重量级锁)并将person对象头中的Mark Word的锁指针指向当前线程,然后第二个线程就会进入阻塞状态。这个时候第一个线程运行结束后推出同步代码块,执行释放轻量级锁的CAS操作就会失败,这个时候线程一知道了锁已经膨胀为重量级锁了,线程一就会释放锁 并唤醒等待的线程,线程二在被唤醒后重新竞争锁。
重量级锁执行的操作
:- 在进入重量级锁后,JVM会给person对象分配一个
ObjectMonitor对象monitor
,并将对象头的Mark Word指向这个monitor对象
,monitor对象内部有两个队列WaitSet
和EntryList
。同时还有一个owner变量
,执行当前获取到锁的线程
。 - 当有多个线程访问person对象锁的同步代码块时,在获取到monitor对象后会
先判断monitor计数器是否为0
为0:
表示可以执行同步代码并将计数 + 1
,同时将monitor对象中的owner指针指向当前线程- 不为0:则表示当前锁被占用,会进入
EntryList中阻塞等待
. - 如果线程调用了person对象的
wait()
方法。会释放当前的monitor
锁,monitor的owner指针为null,计数 - 1.同时进入WaitSet集合斋祀等待被notify唤醒
- 在进入重量级锁后,JVM会给person对象分配一个
# 17. 谈谈 synchronized 和 ReenTrantLock 的区别?
- 可重入性:
ReentrantLock
和Synchronized
都是可重入的锁
,可以一个线程可以反复在当前操作的情况下获取锁
- 锁的实现:
- synchronized锁是
依赖于JVM
实现的。 - ReentrantLock是由
JDK
实现
- synchronized锁是
- 性能方面:
- 在
JDK 1.6 之前
ReentrantLock锁的性能比synchronized性能高许多,但经过JDK 1.6 的优化后,两者的性能差别已经不是很大
- 在
- 功能方面:
- synchronized使用
相对简洁
- ReentrantLock使用方式
更加灵活
,可以决定在什么时候加锁
- synchronized使用
此外:
- ReentrantLock 可以指定是
公平锁
还是非公平锁
。而 synchronized只能是非公平锁 - ReentrantLock 提供了一个Condition类,用来实现
分组唤醒
需要唤醒的线程,而不必想synchronized那样进行随机唤醒一个线程 - ReentrantLock 还提供了一种能够
中断等待锁的线程
的机制,通过lock.lockInterruptibly()
方法实现。
- ReentrantLock 可以指定是
# 18. synchronized 和 volatile 的区别是什么?
- synchronized 保证了
- 原子性
- 可见性
- 有序性
- synchronized是线程安全的
- volatile 保证了多线程的:
- 可见性
- 有序性
- volatile不是线程安全的,并不能保证多线程的并发安全
# 19. 谈一下你对 volatile 关键字的理解?
volatile 关键字
- 保证了不同线程对共享变量操作的可见性(
可见性
) - 禁止对指令的重排序操作 (
有序性
) - 注意:volatile关键字并不能保证
原子性
!
- 保证了不同线程对共享变量操作的可见性(
volatile关键字保证可见性:
- volatile保证可见性就是在线程更新共享变量的时候
让其他的线程中的数据缓存失效
,当其他线程用到这个变量时会重新从主存中拉取
,便是最新的数据了。
- volatile保证可见性就是在线程更新共享变量的时候
volatile关键字保证有序性:
上文中我们提到了JVM的
编译器
和操作系统的处理器
都有可能对指令进行重排序,而被volatile修饰的变量,会禁止这两种重排序
,但对volatile前后没有依赖关系的指令则可以进行重排序。注意:在新的JSR-133规范中,增强了volatile关键字的内存语义,会直接禁用
volatile变量与普通的变量之间的重排序
(这在之前的规范中是被允许的)来确保volatile的写-读和锁的释放、获取具有相同的内存语义。这是为了提供一种比锁更轻量级的线程之间的通信的机制
volatile禁止指令重排序的方式
------内存屏障
上述是volatile对于重排序的一个规则表,从表中可以看出,如果第二个操作是
volatile写操作
,那么不允许重排序
,保证对volatile变量写之前
的操作不会被重排序到写操作之后
;第一个操作是volatile读
操作也不能重排序
,保证对volatile变量的读先于读操作之后的操作;当第一个操作是volatile写
,第二个操作为volatile读
不允许指令重排序。这些都是volatile的内存语义,为了实现这些内存语义,编译器在生成Java字节码的时候会在指令的序列中插入
内存屏障来禁止特定的处理器重排序。JMM对于这个操作采用了比较保守的策略。
- 在每个volatile写前面插入一个StoreStore屏障,保证在进行volatile写操作前的所有写操作都已经刷新到了内存中。
- 在每个volatile写操作的后面插入一个StoreLoad屏障,避免对volatile变量的写操作与后面可能存在的对volatile变量的读写操作发生重排序
- 在每个volatile读操作的后面插入LoadLoad屏障, 避免对volatile的读操作和其后面对普通变量的读操作进行重排序(上面提到的JSR-133规范中的语义的强化)
- 在每个volatile读操作的后面插入LoadStore屏障, 避免volatile的读操作和其后面的普通变量的写操作进行重排序
内存屏障在Java中的体现
- volatile读之后,所有的变量的读写操作都不会重排序到其前面
- volatile读之前,所有的volatile读写操作已经完成
- volatile写之后,volatile变量的读写操作不会重排序到其前面
- volatile写之前,所有变量的读写操作均已完成
经过上面的分析,使用volatile变量的代价是很大的,需要考虑其具体的使用场景
关于volatile,还有更多的知识:MESI、JMM、Happens-Before原则
更多如下:
# 1.MESI(缓存一致性协议)
引入缓存
随着计算机技术的发展,CPU的能力越来越强,可是在CPU变强的同时却遇到了了一个阻碍---数据的读取,CPU的处理能力虽然变强了,但是在拿取数据的时候耗时比较大。
程序需要的数据都必须从主存(通常指RAM)中获取,但主存的速度由于制作的工艺和成本,在速度上没有很大的突破。所以计算机的设计师们就想了一个办法,来平衡掉CPU和主存之间的这部分差异,在主存和CPU的寄存器之间添加缓存,现在已经能够设计三级缓存了
可以发现一级缓存被划分成了两个板块L1i (i是instruction的首字母)和L1d(d是data的首字母)这两种缓存有它们各自的用途,在缓存中越靠近CPU,数据的读取和存储速度就越快。
加入缓存后数据的访问和修改过程便变为了:
- 程序在运行的过程中会先将需要的数据从主存中复制一份到CPU的缓存中,在CPU的计算中直接对缓存中的数据进行操作,在运算结束后再将缓存中的数据回写到主存中。
如果是但线程,上面的操作是没有问题的。但在多线程程序中,数据的安全性便不能得到保证。例如线程1拷贝了变量i的值到缓存中,在代码执行结束后准备将数据写回主存,这个时候线程2也拷贝了i并修改了i的结果,但线程2的执行速度比线程1更快,现将自己改变的i写入了主存,这个时候线程1继续运行,将i的数据写回主存,覆盖了线程2的修改。便出现了数据的安全问题,这类问题有很多经典的例子,例如转账问题。而前面所说的数据安全问题便是缓存的不一致性问题
关于缓存的不一致行问题,可选的有两种解决方案
- 通过对总线加锁
- 这种方式常见于早期的CPU处理器中,而且是一种比较悲观的解决方式,对性能的损耗比较严重。CPU和其他计算机组件之间的通信都是通过总线(数据总线、控制总线、地址总线)进行的,如果直接对总线进行加锁,便会阻塞其他CPU对组件的访问),便有了下面的缓存一致性协议解决的方式
- 通过缓存一致性协议解决
- 缓存一致性协议中比较出名的便是Intel的MESI协议,该协议保证了每一个缓存中使用的共享变量的副本都是一致的。该协议的大致思想是,当CPU在操作缓存中的一个变量时,如果发现了这个变量时共享的一个变量,即在其他的CPU缓存中也存在这个变量的副本,便会进行
- 读:直接读取,不需要做额外的操作
- 写:发一个信号通知其他CPU将该变量的副本置为无效,导致其他的CPU在读取这个共享变量时必须重新从主存中加载,保证了程序数据的每一次变化都是合法的。
- 怎么发现这个数据是否失效呢?通过一种交嗅探的技术
- 缓存一致性协议中比较出名的便是Intel的MESI协议,该协议保证了每一个缓存中使用的共享变量的副本都是一致的。该协议的大致思想是,当CPU在操作缓存中的一个变量时,如果发现了这个变量时共享的一个变量,即在其他的CPU缓存中也存在这个变量的副本,便会进行
# 2.JMM(Java Memory Model)
JMM是Java内存模型的简写。该模型说明了Java虚拟机是如何与计算机的主存打交道的。JDk1.5也对之前的JDK版本中的JMM中的缺陷进行了修补。
在了解JMM之前,先了解多线程的三大特性(原子性、可见性、有序性)
# 1.原子性
原子性是指在一次或多次操作中,要么全部成功,要么全部失败。不会收到其他外界因素的干扰,例如线程的切换。常见的就是多线程中的共享变量i++的问题,i++从语法层面看虽然是一个原子操作,但在字节码中确有很多个操作:get i, i + 1, set i。(这里也说明了volatile关键字不会保证原子性,后面会讲到)
# 2.可见性
既然出现了可见两个字,便说明了一个线程对共享变量的修改是其他线程立即可见的。(多线程下的i++问题便是典型的不可见性(没有同步代码))。
# 3.有序性
有序性是指程序代码的逻辑顺序没有改变。通常我们写的代码都是按照逻辑顺序来的,可是在最后执行字节码或者汇编指令的时候未必是按照我们所写的过程来执行的。因为编译器和系统的处理器会对我们写得代码做底层的指令重排序,来充分发挥计算机的性能。
int x = 10;
int y = 20;
x++;
y = 0;
2
3
4
例如上面这份代码,x和y之间没有数据依赖。最后的执行顺序便可能是这样的
int x = 10;
int y = 20;
y = 0;
x++;
2
3
4
看到这便会说,这和之前的没有问题呀。在单线程中这的确没有问题,但若x和y之间有数据依赖,比如 y = x+1;而且还发生在了多线程程序中,这将是致命的错误。这时候JMM就来帮助我们处理这个问题来了。
上述的Java内存模型是一个抽象的概念,而不是实际的内存模型。JVM中还存在着栈和堆的概念。下图是JVM内存模型和CPU的硬件交互图
如果程序中用到了多线程,每个线程都对应着一个独立的工作内存(抽象概念),每个线程对变量的操作都是本线程可见的。这个时候上述的三个问题也就出现了。下面来看看JMM是如何帮我们解决多线程的三大特性的
# 4.JMM与原子性
- 在java中,基本数据类型的变量的读取赋值操作都是原子性的(y = 10,注意:y = x不是原子操作,包含了读取x和把x赋值给y)。对引用类型的变量的读取和赋值操作同样是原子性的,但多个操作在一起就不具备原子性了。
- JMM只保证了简单的变量的读取和赋值操作是原子性的。如果要某个代码片段是原子性的则需要使用锁来保证。此外,如果想让基本数据类型的自增操作也是原子性的可以使用JUC包下atomic包下的原子类来实现。
# 5.JMM与可见性
在多线程环境下,线程A操作了共享变量X,这个时候线程B是无法感知到的,若想使这一改变对其他线程可见,Java中有三种实现方式
- 使用这一小节的关键字volatile,当一个共享变量被volatile关键字修饰时,表示这个共享的变量的改变是对其他线程立马可见的。当线程A修改了这个变量时,其他线程发现这个变量发生了变化,便会立即从主存中获取(可见volatile关键字是非常耗费性能的)
- 通过前面提到的synchronized关键字进行同步操作,synchronized会保证线程在执行结束时对变量的修改刷新回主存中,同时当另外一个线程进入同步代码块后,会强制重新从主存中拿数据,也解决了共享变量的可见性问题。
- 使用JUC并发包中的显示锁Lock也能解决(也是同步的思想)
# 6.JMM与有序性
前面提到了,为了更好地发挥计算机的性能,在执行指令时会发生指令重新序。这一操作不仅在处理器中会出现,而且在语言的编译器中也会出现。在Java中是允许编译器和处理器对执行进行重排序的。同样也提供了三种方案来保证有序性
- 使用volatile(后续会详细讲到)
- 使用synchronized关键字同步
- 使用JUC中的并发中举lock锁
# 7.happens-before原则
happens-bore原则是用来保证代码的有序性的。如果在我们的程序中仅靠volatile和锁来保证顺序性是非常繁琐的,但我们在写代码的时候并没有关注这一点,只要考虑了多线程的同步问题基本上不会出现顺序性问题。这是因为在Java中有一个Happens-Before(先行发生原则)。通过这个规则,我们能通过几条简单的规则解决并发环境下两个操作之前是否可能存在冲突的所有问题(来自深入理解Java虚拟机Java内存模型与线程章节)
Happens-Before有八个规则
- 程序次序规则
- 在一个线程内,按照编写的控制流的顺序执行(前面的逻辑操作先于后面的逻辑操作)
- 管程(monitor)锁定规则
- 一个unlock先于后面(时间的先后)对同一个锁的lock操作
- volatile变量规则
- 对volatile变量的写操作先于(同样是时间上的先后)对这个变量的读操作
- 线程启动规则
- Thread对象的start()方法先于此线程的每一次操作
- 线程终止规则
- 线程中的所有操作都线于这个线程的终止检测,可以通过Thread.join()方法是否结束,isAlive()方法的返回值等手段检测线程是否已经终止执行
- 线程中断规则
- 调用线程的interrupt()方法先于对被中断的线程的中断检测。可以通过Thread::interrupted方法检测是否有中断发生
- 对象终结规则
- 一个对象的初始化完成先于该对象的finalize()方法的开始
- 传递性规则
- A先于B,B先于C,则A先于C
# 20. 说下对 ReentrantReadWriteLock 的理解?
注意:和ReentrantLock没有关系,这两个锁都是单独的实现。
是ReadWriteLock接口的一个实现类,实现了并发度,互斥
可重入的读写锁,用于读多写少的场景
读写锁内部维护了两个锁,一个用于读操作,一个用于写操作。
所有 ReadWriteLock实现都必须保证 writeLock操作的内存同步效果也要保持与相关 readLock的联系。也就是说,成功获取读锁的线程会看到写入锁之前版本所做的所有更新。
ReentrantReadWriteLock支持以下功能:
- 支持公平和非公平的获取锁的方式;
- 支持可重入。读线程在获取了读锁后还可以获取读锁;写线程在获取了写锁之后既可以再次获取写锁又可以获取读锁;
还允许从写入锁降级为读取锁,其实现方式是:
先获取写入锁,然后获取读取锁
,最后释放写入锁
。但是,从读取锁升级到写入锁
是不允许
的;读取锁和写入锁都支持锁获取期间的中断;
Condition支持。仅写入锁提供了一个 Conditon 实现;读取锁不支持 Conditon ,readLock().newCondition() 会抛出 UnsupportedOperationException。
# 21. 说下对悲观锁和乐观锁的理解?
# 1.乐观锁和悲观锁
- 悲观锁:
- 悲观锁,自然是很悲观的,在线程每次拿数据的时候都会认为别人会修改数据,因此在每次拿数据的时候就会对这些共享的资源进行加锁。如果别的线程想拿这些数据,就必须阻塞等待,知道线程释放掉锁。悲观锁具有非常强烈的独占性和排他性
- 应用场景:传统的关系型数据库中很多地方都用到了这种锁:数据库的行锁、表锁、读锁、写锁等。Java中的synchronized锁和ReentrantLock就是典型的悲观锁
- 乐观锁:
- 乐观锁不会像悲观锁那么悲观,它在获取数据的时候不会认为该数据已经被修改,故不会进行上锁操作,但在更新数据的时候会判断这个数据是否发生了变化(使用版本号限制)。如果发生了变化就会使用CAS重试,直到修改成功。
- 应用场景:乐观锁更适用于读多写少的场景,JUC包中的atomic原子变量类就是使用乐观锁的一种实现方式CAS
# 22. 乐观锁常见的两种实现方式是什么?
# 1.乐观锁实现方式
版本号机制:可以在共享的变量中加一个版本号,每次更新变量时判断版本号是否和当前的版本号一致,如果一致则直接写入,并将版本号+1,如果不一致则进行重试,重新获取版本号,重新更新。
CAS(Compare And Swap)算法:CAS是一种无锁算法,一般也叫非阻塞同步。因为CAS操作不会阻塞运行的线程来实现数据的同步。
CAS操作中包含了三个操作数
- 读写的数据值V
- 进行比较的值A
- 即将写入的新值B
当V值和进行比较的值A相等时才能保证当前数据没有被其他线程改变过,才将数据值V改为B。在这个过程中执行的是原子操作(原子操作不能被打断,同时变量的自增操作不是原子操作)
CAS操作的缺点:
- 常见的ABA问题,就是一个数据由原来的A变为B,然后再变回A,这个时候用CAS操作直接判断了这个数据是没有发生变化了,导致出现了一种幻觉。这个问题可以通过引入一个版本来解决。在JDK1.5后的JUC包下有一个atomic原子包,里面提供了一个AtomicStampedReference来解决ABA问题,这个类的compareAndSet方法就会先检查当前的引用是否等于预期的引用,当前的标志是否等于预期的标志(可以是version版本号),如果全部相等的话就会成功更新值。(这个方法放到后面的原子类中详细了解)
- 循环时间长后开销较大。CAS操作无非使用的就是一个死循环在比较操作,但如果长时间的CAS操作不成功,CPU的消耗巨大,同时降低系统的吞吐量。
- 只能保证一个共享变量的原子操作。在使用CAS更新数据时,只能对一个变量的数据进行更新,如果同时对多个变量使用CAS操作就无法保证原子操作。但JDK1.5也给出了解决方案,提供了一个交AtomicReference的类保证引用对象之间的原子性,便可以把多个对象同时进行CAS操作
# 2.悲观锁的实现方式:synchronized和JDK中的其他的锁
# 23. 乐观锁的缺点有哪些?
- 乐观锁采用CAS的方式进行实现,而CAS的实现方式的缺点有:
- 循环时间长后开销较大:CAS操作无非使用的就是一个死循环在比较操作,但如果长时间的CAS操作不成功,CPU的消耗巨大,同时降低系统的吞吐量。
- ABA 问题:值被修改了,然后又被改回来了
# 24. CAS 和 synchronized 的使用场景?
- CAS使用与读多写少的场景(如果写操作过多,会过度争抢CPU,从而降低系统的吞吐量)
- synchronized
# 25. 简单说下对 Java 中的原子类的理解?
Java 从JDK 1.5 开始提供了一种用法更简单、性能高效、线程安全地更新一个变量的值的方式
Java 提供了 4 中类型的原子更新的方式
- 原子更新基本类型
- 原子更新数组类型
- 原子更新引用类型
- 原子更新属性(字段)
Java 的 原子更新类基本上都是使用
Unsafe
类实现的包装类举例AtomicInteger 类中的一个incrementAndGet()方法
使用
public class AtomicClassUse { private static AtomicInteger value = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[10]; for(int i = 0;i < 10;i++){ threads[i] = new Thread(() -> { for (int j = 0; j < 10000; j++) { value.incrementAndGet(); } }); } for (int i = 0; i < 10; i++) { threads[i].start(); } for (int i = 0; i < 10; i++) { threads[i].join(); } System.out.println(value.get()); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
简单源码:
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; // 这里在返回的旧值加上了1 } public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); // 获取当前的volatile变量的值 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); // 比较并交换Int值,将获取的值加上待添加的值。 return var5; // 返回旧值。 }
1
2
3
4
5
6
7
8
9
10
11
使用
AtomicReference
类会出现典型的ABA问题,故JDK中提供了AtomicStampReference
类来解决这个问题# AtomicStampedReference
AtomicStampedReference是一个带有时间戳的对象引用类。他的内部不仅维护了对象值,还维护了一个时间戳,当AtomicStampedReference中数据被更改时,同时更改对象中的时间戳,这样在AtomicStampedReference在设置对象的值的时候会判断当前的时间戳是否发生了改变,如果发生了改变则修改失败,重新进行。这样便能防止不恰当的数据的更改,也能解决ABA问题。
AtomicStampedeReference的内部维持了一个Pair对象,pair对象中存放了对象的引用和时间戳(或者版本号)。其中的compareAndSet()方法变成了这样: 具体可以自行查看JDK中关于这块的源码。
//V就是对应的存储的对象类型,多了两个参数,期望的stamp和当前的stamp public boolean compareAndSet(V expectedReference,V newReference,int expectedStamp,int newStamp) {...}
1
2在加入stamp这一限制后,如果stamp的值发生了变化则会更新失败,也就避免了ABA问题。
# 26. atomic 的原理是什么?
atomic底层使用的是CAS操作
即Unsafe
类中的native
方法
Unsafe类
compareAndSwapInt()
方法是Unsafe类中的一个本地方法,此外Unsafe类还提供了其他的一些方法
public native int getInt(Object o, long offset); // 获取给定对象给定偏移量上的int值
public native int putInt(Object o, long offset,int x); // 设置给定对象给定偏移量上的int值为x
public native long objectFieldOffset(Field var1); // 获取字段在对象中的偏移量
public native void putIntVolatile(Object o, long offset,int x); // 使用volatile语义设置给定对象的int值为x
public native int getIntVolatile(Object o, long offset); // 使用volatile语义获取给定对象的偏移量上的int值
public native void putOrderedInt(Object 9, long offset, int x); // 和putIntVolatile方法一样,要求字段是volatile类型的变量。
2
3
4
5
6
JDK的开发人员是不希望我们使用这个类,可能会造成一些内存泄露的风险。
# 27. 说下对同步器 AQS 的理解?
- 什么是AQS:
AQS(AbstractQuenedSynchronizer)
:全称抽象队列同步器,是用来构建锁或者其他同步组件的基础框架
。- AQS 使用了一个
int成员变量
表示同步状态
,通过内置的FIFO队列
来完成资源获取线程的排队工作
,是一个可以用来实现线程同步的基础框架。 - AQS 本身没有提供实现任何同步接口,只是实现了线程同步所需要的基本方法;仅仅只是定义了若干同步状态获取和释放的方法来供自定义同步组件使用
- AQS 既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态 (如ReentrantLock、ReentrantReadWriteLock等)
- AQS 是 面向锁的实现者 而非使用者
- AQS 中 封装了很多方法
- 获取独占锁
- 释放独占锁
- 获取共享锁
- 释放共享锁
- 我们需要继承AQS创建它的子类对象来使用它,一般都是以
内部类
的方式继承
AQS,然后在自己的类中产生并使用这个对象- 比如
ReentrantLock
就是定义了一个抽象内部类Sync
继承自AQS,然后定义了NonfairSync
和FairSync
两个类继承自Sync类,来实现公平锁和非公平锁
- 比如
- 如果要重写AQS指定的方法时,需要使用AQS提供的以下三个方法来访问或修改同步状态:
getState()
: 获取当前同步状态setStatte(int new State)
: 设置当前同步状态compareAndSetState(int expect,int update)
: 使用CAS设置当前状态,此方法能保证设置状态的原子性
# 28. AQS 的原理是什么?
底层的数据结构
- AQS内部使用了**
FIFO (双向链表)
将等待线程链接起来,当发生并发竞争的时候,就会初始化该队列并让线程进入睡眠等待唤醒;同时每个节点会根据是否为共享锁标记状态为共享模式
或独占模式
**。 - FIFO 中的
每个Node
都是由线程封装
,内部使用的是volatile
类型的变量当线程争抢锁失败后会封装成一个Node加入到AQS队列中去;当获取锁的线程释放锁后,会从队列中唤醒一个阻塞的节点。
- AQS内部使用了**
AQS中的成员变量
state
:AQS底层维护了一个int类型的
volatile
变量state
来标识当前的同步状态,根据当前state的值来判断当前是否处于锁定状态
,或者是其他状态
同时通过一下三个方法来获取和修改同步状态
getState()
: 获取当前同步状态setStatte(int new State)
: 设置当前同步状态compareAndSetState(int expect,int update)
: 使用CAS设置当前状态,此方法能保证设置状态的原子性
AQS 实现同步的机制:
- AQS通过一个同步队列来维护 当前获取锁失败 (主要是通过
acquire()
和acquireShared()
获取锁),进入阻塞状态的线程;这个同步队列是一个双向链表,获取锁失败的线程会被封装成一个链表结点,加入同步队列的尾部排队;而AQS则保存了链表的头结点的引用head
和尾结点的引用tail
。
- AQS通过一个同步队列来维护 当前获取锁失败 (主要是通过
对资源的共享方式有两种:
独占:只有一个线程能执行。可分为公平锁和非公平锁。
- 公平锁:按照线程在队列中的派对顺序先到先得锁
- 非公平锁:当线程要获取锁,无视队列顺序直接去抢锁,谁获取锁谁就执行。
共享:锁可以由多个线程同时获取,锁每被获取一次对应的锁的计数器便 + 1;比如典型的读写锁
AQS中的独占锁 (acquire()方法) 的获取过程:
- 调用
tryAcquire()
(自实现的方法)方法尝试获取一次锁,返回true则表示获取成功,若返回false则执行2
- 调用
执行
acquireQueued(0)
方法,这个方法的传入参数调用了addWriter方法addWriter()
方法将当前线程封装成同步队列的结点,然后加入到同步队列的尾部进行排队,并返回此结点addWriter()
方法执行完过后调用aquireQueued()
方法,让当前线程在同步队列中阻塞,然后在其他线程释放锁后唤醒阻塞线程时再去争抢锁若线程被唤醒并成功获取锁后,将到死
acquireQueued()
方法中退出,同时返回一个boolean值表示当前线程是否被中断,若中断则会执行selfInterrupt()
方法相应中断。
acquire()
方法的源码public final void acquire(int arg) { // tryAcquire() 方法是 锁的实现者自己继承AQS后自行实现的方法 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
1
2
3
4
5
acquireQueued()
方法的源码实现:/** * 让线程不间断地获取锁,若线程对应的节点不是头节点的下一个节点,则会进入等待状态 * @param node the node */ final boolean acquireQueued(final Node node, int arg) { // 记录失败标志 boolean failed = true; try { // 记录中断标志,初始为true boolean interrupted = false; // 循环执行,因为线程在被唤醒后,可能再次获取锁失败,需要重写进入等待 for (;;) { // 获取当前线程节点的前一个节点 final Node p = node.predecessor(); // 若前一个节点是头节点,则tryAcquire尝试获取锁,若获取成功,则执行if中的代码 if (p == head && tryAcquire(arg)) { // 将当前节点设置为头节点 setHead(node); // 将原来的头节点移出同步队列 p.next = null; // help GC // 失败标志置为false failed = false; // 返回中断标志,acquire方法可以根据返回的中断标志,判断当前线程是否被中断 return interrupted; } // shouldParkAfterFailedAcquire方法判断当前线程是否能够进入等待状态, // 若当前线程的节点不是头节点的下一个节点,则需要进入等待状态, // 在此方法内部,当前线程会找到它的前驱节点中,第一个还在正常等待或执行的节点, // 让其作为自己的直接前驱,然后在需要时将自己唤醒(因为其中有些线程可能被中断), // 若找到,则返回true,表示自己可以进入等待状态了; // 则继续调用parkAndCheckInterrupt方法,当前线程在这个方法中等待, // 直到被其他线程唤醒,或者被中断后返回,返回时将返回一个boolean值, // 表示这个线程是否被中断,若为true,则将执行下面一行代码,将中断标志置为true if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { // 上面代码中只有一个return语句,且return的前一句就是failed = false; // 所以只有当异常发生时,failed才会保持true的状态运行到此处; // 异常可能是线程被中断,也可能是其他方法中的异常, // 比如我们自己实现的tryAcquire方法 // 此时将取消线程获取锁的动作,将它从同步队列中移除 if (failed) cancelAcquire(node); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
addWriter()
方法的源码实现// 将线程封装成一个节点,放入同步队列的尾部 private Node addWaiter(Node mode) { // 当前线程封装成同步队列的一个节点Node Node node = new Node(Thread.currentThread(), mode); // 这个节点需要插入到原尾节点的后面,所以我们在这里先记下原来的尾节点 Node pred = tail; // 判断尾节点是否为空,若为空表示队列中还没有节点,则不执行以下步骤 if (pred != null) { // 记录新节点的前一个节点为原尾节点 node.prev = pred; // 将新节点设置为新尾节点,使用CAS操作保证了原子性 if (compareAndSetTail(pred, node)) { // 若设置成功,则让原来的尾节点的next指向新尾节点 pred.next = node; return node; } } // 若以上操作失败,则调用enq方法继续尝试(enq方法见下面) enq(node); return node; } private Node enq(final Node node) { // 使用死循环不断尝试 for (;;) { // 记录原尾节点 Node t = tail; // 若原尾节点为空,则必须先初始化同步队列,初始化之后,下一次循环会将新节点加入队列 if (t == null) { // 使用CAS设置创建一个默认的节点作为首届点 if (compareAndSetHead(new Node())) // 首尾指向同一个节点 tail = head; } else { // 以下操作与addWaiter方法中的if语句块内一致 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
AQS 中的独占锁的释放 (
release()
方法)过程:- 调用
tryRelease()
(自实现的方法)尝试修改state来释放锁,若修改成功则返回true并执行2,否则返回false
- 调用
- 若修改state成功,则表示释放锁成功,需要将当前线程移除同步队列,移出的过程如下 (可配合上面的结构图看):
release()
方法的源码实现为:public final boolean release(int arg) { // 调用tryRelease尝试修改state释放锁,若成功,将返回true,否则false if (tryRelease(arg)) { // 若修改state成功,则表示释放锁成功,需要将当前线程移出同步队列 // 当前线程在同步队列中的节点就是head,所以此处记录head Node h = head; // 若head不是null,且waitStatus不为0,表示它是一个装有线程的正常节点, // 在之前提到的addWaiter方法中,若同步队列为空,则会创建一个默认的节点放入head // 这个默认的节点不包含线程,它的waitStatus就是0,所以不能释放锁 if (h != null && h.waitStatus != 0) // 若head是一个正常的节点,则调用unparkSuccessor唤醒它的 后继 节点所对应的线程 (一般是下一个结点,但若发现下一个结点为空则继续向后查找) unparkSuccessor(h); // 释放成功 return true; } // 释放锁失败 return false; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AQS 中的 共享锁 (
acquireShared()
方法)的获取过程:- 调用
tryAcquireShared()
(自实现方法)方法获取共享锁 (实际上是尝试修改state的值),如果返回值 >=0 则获取成功,如果<=0 则执行2
- 调用
调用
doAcquiredShared()
方法,该方法会不断获取共享锁,若线程对应的结点不是头结点的下一个结点,将进入等待状态doAcquireShared
方法 的而实现和获取独占锁的acquireQueued方法很类似,但是主要有一点不同,那就是在线程被唤醒后,若成功获取到了共享锁,还需要判断共享锁是否还能被其他线程获取,若可以,则继续向后唤醒它的后继节点对应的线程。
acquireShared()
方法的源码实现:public final void acquireShared(int arg) { // tryAcquireShared() 方法是锁的实现者自行实现的 if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }
1
2
3
4
5
doAcquireShared()
方法的源码实现:/** * 不间断地获取共享锁,若线程对应的节点不是头节点的下一个节点,将进入等待状态 * 实现与acquireQueued非常类似 * @param arg the acquire argument */ private void doAcquireShared(int arg) { // 往同步队列的尾部添加一个默认节点,Node.SHARED是一个Node常量, // 它的值就是一个不带任何参数的Node对象,也就是new Node(); final Node node = addWaiter(Node.SHARED); // 失败标志,默认为true boolean failed = true; try { // 中断标志,用来判断线程在等待的过程中释放被中断 boolean interrupted = false; // 死循环不断尝试获取共享锁 for (;;) { // 获取默认节点的前一个节点 final Node p = node.predecessor(); // 判断当前节点的前一个节点是否为head节点 if (p == head) { // 尝试获取共享锁 int r = tryAcquireShared(arg); // 若r>0,表示获取成功 if (r >= 0) { // 当前线程获取锁成功后,调用setHeadAndPropagate方法将当前线程设置为head // 同时,若共享锁还能被其他线程获取,则在这个方法中也会向后传递,唤醒后面的线程 setHeadAndPropagate(node, r); // 将原来的head的next置为null p.next = null; // help GC // 判断当前线程是否中断,若被中断,则调用selfInterrupt方法响应中断 if (interrupted) selfInterrupt(); // 失败标志置为false failed = false; return; } } // 以下代码和获取独占锁的acquireQueued方法相同,即让当前线程进入等待状态 // 具体解析可以看上面acquireQueued方法的解析 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
AQS 中的 共享锁 的释放
releaseShared()
过程- 调用 tryReleaseShared() 方法,尝试修改state的值来释放锁,若成功则执行 2 并返回 true,否则返回 false
- 执行 doReleaseShared() 方法 来唤醒后继节点对应的线程,让他开始尝试获取锁,若修改state失败,则返回false
AQS 使用 Locksupport 工具 实现线程等待机制
LockSupport
工具类定义了很烦多静态方法,当需要阻塞或唤醒一个线程时,就可以直接调用这个类中的方法,骂她的底层实现是通过Unsafe类的对象,Unsafe类的放阿飞都是native方法阻塞线程:调用
LockSupport
类中的park
方法唤醒一个线程:调用
LockSupport
类中的unpark
方法
此外AQS 还 支持其他获取锁的方式,比如超时等待获取锁,不过这些和普通的获取锁的方式差不多,只是加入了计时方式,一旦超则自动返回;
参考博客:
- https://www.cnblogs.com/tuyang1129/p/12670014.html
- https://www.cnblogs.com/knowledgeispower/p/16654916.html
《并发编程艺术》第五章队列同步器
# 29. AQS 对资源的共享模式有哪些?
# 30. AQS 底层使用了模板方法模式,你能说出几个需要重写的方法吗?
以下是AQS需要重写的方法(摘自 《并发编程的艺术》)
# 31. 说下对信号量 Semaphore 的理解?
Semaphore 是一个计数信号量。可管理一系列的许可,每个acuqire() 获取锁的方法阻塞,知道有一个俩率尔正可以获得然后拿走一个许可证;每个release方法增加一个许可,同时释放一个阻塞的acquire() 方法。但并不存在实际的许可对象,内部只是维持了一个可获得许可证的数量。
Semaphore 是一个线程同步的辅助类,可以维护当前访问自身的线程个数,并提供了同步机制。使用Semaphore可以控制同时访问资源的线程个数, 例如是实现一个文件允许的并发访问数。
Semaphore 是基于 AQS 实现,底层的数据结构也依托于 AQS
Semaphore实现了
- NofairSync 非公平锁
- FairSync 公平锁
- 这两种锁都继承自自实现的 Sync 内部类
Semaphore 实现的是
资源的互斥
而不是资源的同步
Semaphore 的使用场景:
- Semaphore 常用于资源有明确访问数量限制的场景,例如限流操作。
- 例如:可用于数据库连接池,同时进行连接的线程有数量限制,连接不能超过一定的数量,当连接到达了限制数量后,后面的线程只能排队等前面的线程释放了书蛮甩甩连接才能获取数据库连接。
# Semaphore 原理实现:
参考博客
- https://www.cnblogs.com/crazymakercircle/p/13907012.html
- https://www.cnblogs.com/leesf456/p/5414778.html
# 32. CountDownLatch 和 CyclicBarrier 有什么区别?
本质:
CountDownLatch 是一个同步的辅助类,允许一个或多个线程,等待其他一组线程完成指定的操作,再继续执行
CyclicBarrier 是一个同步的辅助类,允许一组线程
相互之间
的等待,达到一个共同点
,再继续执行 ( 可以有多个共同点,也即是可以多次执行)
可运行次数:
CountDownLatch 只会运行一次
但CyclicBarrier 可以重复执行。
await()的用处:
CountDownLatch的await方法是用于等待线程的,
而CyclicBarrier 用于任务线程 ( 即多个线程在达到一个共同点后可以继续执行后续任务,后面继续调用await() 方法;例如一群士兵需要先集合完毕再一起出发完成后续的任务)
实现不同:
CountDownLatch 是基于AQS 实现;
而CyclicBarrier 是基于ReentrantLock 和Condition实现
CyclicBarrier的额外用法:
CyclicBarrier 可以传入一个
barrierAction 参数
来实现在所需要等待的线程的全部都执行结束后优先执行barrierAction
动作;例如士兵集合后需要安排任务,便可以这样执行。CyclicBarrier
还提供了其他有用的方法,比如getNumberWaiting
方法可以获取CyclicBarrier阻塞的线程数量
;isBroken()
方法用来了解阻塞的线程是否被中断
。
应用场景:
CountDownLatch
的应用场景:- 将任务
分割成多个子任务
,每个子任务由单个线程去完成,等所有的线程完成之后再将结果汇总
;这种场景下CountDownLatch 是作为一个完成信号来使用。 - 让
多个线程等待
,一直等待某个条件发生,织入多个赛跑运动员都做好了准备,等待裁判的发令枪响;这种场景下可以将CountDownLatch
的初始值
设置为1
。
- 将任务
CyclicBarrier的应用场景:
- CyclicBarrier 通常用于
分组计算
- CyclicBarrier 通常用于
CountDownLatch 实现:
CountDownLatch 是通过
共享的方式
获取和释放同步状态的。主要是使用AQS
中的state
值CountDownLatch 的 每次
countDown()
方法 都会 相当于释放一次共享锁
tryReleaseShared() 方法的实现如下:
protected boolean tryReleaseShared(int releases) { // Decrement count; signal when transition to zero for (;;) { // 获取当前的state值 int c = getState(); // 如果 state 的值已经为0 则释放锁失败 if (c == 0) return false; int nextc = c-1; // 以 CAS 的方式更新同步状态值 if (compareAndSetState(c, nextc)) return nextc == 0; } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14该方法使用
CAS + 自旋
的方式将计数值 state 减少1。仅当更新操作成功且state值被更新为0时返回true,表示在共享模式下释放同步状态成功
,接着便会继续执行
- CountDownLatch 通过 调用
await
方法来判断阻塞当前的等待线程
,若其他任务线程都已完成了执行,则当前线程会成功获取共享锁
,继续执行。
该方法调用 AQS 的
acquireSharedInterrupbily
方法并支持中断
public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) // 支持中断获取锁,如果成功获取锁就继续执行 (参考AQS 中的具体源码) doAcquireSharedInterruptibly(arg); }
1
2
3
4
5
6
7
8
9
- CountDownLatch 是以
AQS 的共享模式
来实现复杂的并发流程控制。当器内部的计数器不为0
时,调用器await
方法将导致线程尝试获取锁
并加入同步队列并阻塞
,当调用countDown方法时计数器的值为0时,会唤醒队列中第一个等待的线程
,之后由该线程唤醒后继线程
,直到
阻塞在锁上的所有线程都被成功唤醒
(因为可能有很多个线程在等待同一个任务)
CyclicBarrier 实现:
CyclicBarrier 是通过
ReentrantLock
和Condition
共同构建的。CyclicBarrier 在
每次
调用await
是都会将count - 1
,操作count值时直接使用ReentrantLock
来保证线程安全。如果count不为0
,则添加到condition链表
中,如果count等于0,则把结点从condition
添加至AQS的队列
中进行全部唤醒,并且将parties的值重新复制为count的值
(以达到复用的目的)主要的doWait方法中 关于 barrierAction的操作:
int index = --count; // 如果计数器的值 达到了0 则优先执行barrierAction if (index == 0) { // tripped boolean ranAction = false; try { final Runnable command = barrierCommand; // 如果传入的 barrierAction参数不为null则优先执行barrierAction中的操作。 if (command != null) command.run(); ranAction = true; // 设置下一轮 barrier nextGeneration(); // 本次任务的所有工作都已完成 最终会释放本次的锁 return 0; } finally { // 如果在执行的过程中发生了异常或没有成功运行 则将 当前的 barrier 设置成 已损坏并通知所有等待的线程 ( 该方法只能在获取锁后才能执行) if (!ranAction) breakBarrier(); } } private void nextGeneration() { // signal completion of last generation // 唤醒所有等待的线程 trip.signalAll(); // set up next generation // 执行准备 下一次 barrier 的操作 count = parties; generation = new Generation(); } private void breakBarrier() { generation.broken = true; count = parties; trip.signalAll(); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Condition
- Condition 是 JDK 1.5中新增工具;可用来替换传统的Object的wait()、notify() 来实现线程间的协作,相比使用Object的 wait()、notify(),使用Doncdition的 await() signal() 这种方式实现线程键协作更加安全和高效,因此通常来说比较推荐使用Condition。
- Condition 可以实现多路同志的功能,可在一个Lock对象李创建多个Condition实例,线程对象可以注册在指定的Condition中,从而可以有选择的进行线程的通知,在调度线程上更加灵活。
- Condition 只是一个接口,基本的方法就是 await() 和 signal()
- Condition 依赖于Lock接口,生成一个Condition的基本代码就是lock.newCondition()
- Condition 的使用必须先获取锁 即必须在之前调用 lock() 方法
参考博客
- https://www.cnblogs.com/dw3306/p/16320667.html
- https://www.cnblogs.com/takumicx/p/9698867.html#23--countdownlatch%E5%8E%9F%E7%90%86%E6%B5%85%E6%9E%90
- https://www.cnblogs.com/xfeiyun/p/15871009.html
# 33. 说下对线程池的理解?为什么要使用线程池?
线程池使用一种池化的技术,目的是为了达到资源的高效利用
线程是一种宝贵有限的资源,每一次创建线程都会需要昂贵的代价;而在服务器端多线程开发是常态,如果频繁地创建和销毁线程会对服务器造成压力;从而降低系统的吞吐量。
为了更好地管理线程和降低这种开销,JDK的设计人员采用了一种池化的技术,使得线程得以复用。
# 34. 创建线程池的参数有哪些?
- 工作队列 :仅用于保存
execute()
(注意:submit() 方法内部调用了 execute() 方法) 方法提交但线程池中的线程来不及处理的任务,是阻塞队列 - 核心线程大小:线程中的活跃数量
- 最大线程数量:线程池可容纳的最大线程数量
- keepAliveTime: 当多于核心线程数量的线程在等待新的任务到达之前可存活的最长时间 (
需配合 unit
); - 线程池工厂:提供创建线程的线程工厂,例如自定义线程或线程组的名称 便于后面 dump
- 拒绝策略 (
实现了RejectedExecutionHandler
):用于任务数量超过工作队列的容量时执行的拒绝策略 (是直接丢弃或者记录) - 注意: 如果传入的阻塞队列是,无界队列,那么传入的最大线程数量和拒绝策略将没有任何意义,因为无界队列不存在被装满的情况,而线程的扩容,拒绝策略的执行都取决于阻塞队列是否被装满。
# 35. 如何创建线程池?
使用
Executors
工具类中存在用于种类型任务的线程池创建方法:Executors.newCachedThreadPool
:创建一个可缓存的线程池,如果线程池的大小超过了需要,可以灵活回收空闲线程,如果没有可回收线程,则新建线程
- 注意:使用这种方式创建的线程池,可允许创建的线程数量为
Integer.MAX_VALUE
,可能会创建大量线程,从而导致OOM
Executors.newFixedThreadPool
:创建一个定长的线程池,可以控制线程的最大并发数,超出的线程会在队列中等待
- 注意:以这种方式创建的线程池,由于使用了无界阻塞队列
LinkedBlockingQueue
,而该阻塞队列的最大长度为Integer.MAX_VALUE
可能会堆积大量的请求,最终导致OOM
Executors.newScheduledThreadPool
:创建一个定长的线程池,支持定时、周期性的任务执行
- 注意:以这种方式创建的线程池,也会出现 1 种类型的 OOM,可允许创建的线程舒亮亮为
Integer.MAX_VALUE
;
Executors.newSingleThreadExecutor
: 创建一个单线程化的线程池,使用一个唯一的工作线程执行任务,保证所有任务按照指定顺序(先入先出或者优先级)执行
- 注意:以这种方式创建的线程池,也会出现 2 种类型的 OOM
Executors.newSingleThreadScheduledExecutor
:创建一个单线程化的线程池,支持定时、周期性的任务执行Executors.newWorkStealingPool
:创建一个具有并行级别的work-stealing线程池
为什么不推荐使用
Executors
工具类创建线程池 反而推荐直接使用ThreadPoolExecutor构建线程池?- 使用该工具类无法控制 队列的长度 和 线程的最大数量,很容易出现OOM
- 该工具类中的大部分方法都不能自定义线程池以及线程的名称。
- 直接使用
ThreadPoolExecutor
构建线程池会更灵活,更利用排查问题
线程池的运行过程:
# 36. 线程池中的的线程数一般怎么设置?需要考虑哪些问题?
线程池中线程执行任务的性质:
CPU 密集型任务:
计算密集型的任务比较占 CPU,所以一般线程数设置的大小 等于或者略微大于 CPU 的核数;IO 密集型任务:
但 IO 型任务主要时间消耗在 IO 等待上,CPU 压力并不大,所以线程数一般设置较大;例如发邮件任务
cpu 使用率:
- 当线程数设置较大时,会有如下几个问题:
- 第一,线程的初始化,切换,销毁等操作会消耗不小的 cpu 资源,使得
cpu 利用率一直维持在较高水平
。 - 第二,线程数较大时,任务会短时间迅速执行,任务的集中执行也会给 cpu 造成较大的压力。
- 第三,任务的集中执行,会让 cpu 的使用率呈现锯齿状,即短时间内 cpu 飙高,然后迅速下降至闲置状态,cpu 使用的不合理,应该减小线程数,让任务在队列等待,使得 cpu 的使用率应该持续稳定在一个合理,平均的数值范围。所以 cpu 在够用时,不宜过大,不是越大越好。可以通过上线后,观察机器的 cpu 使用率和 cpu 负载两个参数来判断线程数是否合理。
- 第一,线程的初始化,切换,销毁等操作会消耗不小的 cpu 资源,使得
- 当线程数设置较大时,会有如下几个问题:
内存使用率:
- 线程数过多和队列的大小都会影响此项数据,队列的大小应该通过前期计算线程池任务的条数,来合理的设置队列的大小,不宜过小,让其不会溢出,因为溢出会走拒绝策略,多少会影响性能,也会增加复杂度。
下游系统抗并发能力:
- 多线程给下游系统造成的并发等于你设置的线程数,例如:如果是多线程访问数据库,你就考虑数据库的连接池大小设置,数据库并发太多影响其QPS,会把数据库打挂等问题。如果访问的是下游系统的接口,你就得考虑下游系统是否能抗的住这么多并发量,不能把下游系统打挂了。
参考回答:
- https://six.club/question/6598
# 37. 执行 execute() 方法和 submit() 方法的区别是什么呢?
- 接收参数不同
- submit() 有返回值,但 execute() 没有返回值
- submit() 可以进行 Exception 异常处理
# 38. 如何设计一个线程池?
需要理解线程池的运行原理
可参考博客
- https://www.cnblogs.com/Damaer/p/15228526.html
# 38. 说下对 Fork和Join 并行计算框架的理解?
暂无;分开计算,再合并
# 39. JDK 中提供了哪些并发容器?
- ConcurrentHashMap:高效的具有并发安全的HashMap
- ConcurrentLinkedQueue:高效的具有并发安全的队列,可理解为线程安全的LinkedQueue
- ConcurrentSkipListMap:跳表的实现,底层使用跳表的数据结构
- ConcurrentSkipListSet:基于 ConcurrentSkipListMap 的可扩展并发 NavigableSet 实现
- CopyOnWriteArrayList:读写分离List,可用于读多写少的并发情况
- BlockingQueue:一个接口,意味着阻塞队列
# 40. 谈谈对 CopyOnWriteArrayList 的理解?
- 读写分离
- 写操作会复制到另外一个数组进行
- 写操作需要加锁
- 写操作结束后需要将原始数组指向修改后的数组
- 试用场景
- 读多写少的场景
- 缺陷
- 占用内存高;在进行写操作的时候会复制到一个新的数组中,内存占用多了一倍
- 数据一致性问题:读操作不能实时获取最新的数据,导致在修改时用户读取的数据不一致的问题。
# 41. 谈谈对 BlockingQueue 的理解?分别有哪些实现类?
阻塞队列
BlockingQueue 实际上是一个接口;该接口标识着阻塞队列;
BlockingQueue通常用于一部分线程生产对象,另外一部分线程来消费对象
一个线程将会持续生产新对象并将其插入到队列中,直到队满,如果队满则生产者线程将会被阻塞,直到消费者从队列中消费一个对象;消费者线程会持续从队列中去消费对象,直到队列中的对象数量为空便等待生产线程向队列生产对象。
BlockingQueue中的方法
BlockingQueue具有4种不同的方法用于插入移除队列
| | 特定值 | 阻塞 | 抛异常 | 超时 | | ---- | ------- | ------ | --------- | ------- | | 插入 | offer() | put() | add() | offer() | | 移除 | poll | take() | remove() | poll() | | 检查 | peek | | element() | |
四种执行方式解释
抛异常: 如果试图的操作无法立即执行,抛一个异常。
特定值: 如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false);如根据队列的容量立即返回插入结果。
阻塞: 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
超时: 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
使用BlockingQueue中的remove() 方法来移除已经放入队列中的元素操作效率非常低,一般不建议执行此方法。
BlockingQueue队列的实现
- ArrayBlockingQueue
- ArrayBlockingQueue是一个有界的阻塞队列,底层是一个固定容量的数组,不支持扩容;
- ArrayBlockingQueue 内部以 先进先出 (FIFO) 的顺序对元素进行存储
- LinkedBlockingQueue
- LinkedBlockingQueue,底层是以链表形式对元素进行存储,如果没有设置容量上限的话则以Integer.MAX_VALUE为队列的最大容量
- LinedBlockingQueue 同样也是以FIFO的顺序对元素进行存储
- DelayQueue
- DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉。
- PriorityBlockingQueue
- PriorityBlockingQueue 使用了和 PriorityQueue 一样的排序规则,需要自行实现Comparable来自定义元素的排列顺序
- 注意:如果使用PriorityBlockingQueue的Iterator来遍历队列,则元素的顺序不一定是按照预期的优先级顺序进行排列
- SynchronizedQueue
- SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。
- ArrayBlockingQueue
参考博客:
- https://pdai.tech/md/java/thread/java-thread-x-juc-collection-BlockingQueue.html
# 42. 谈谈对 ConcurrentSkipListMap 的理解?
ConcurrentSkipListMap是线程安全的有序的哈希表,适用于高并发的场景。
ConcurrentSkipListMap和TreeMap,它们虽然都是有序的哈希表。但是,第一,它们的线程安全机制不同,TreeMap是非线程安全的,而ConcurrentSkipListMap是线程安全的。第二,ConcurrentSkipListMap是通过跳表实现的,而TreeMap是通过红黑树实现的。
- 在4线程1.6万数据的条件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右。但ConcurrentSkipListMap有几个ConcurrentHashMap 不能比拟的优点:
- ConcurrentSkipListMap 的key是有序的。
- ConcurrentSkipListMap 支持更高的并发。ConcurrentSkipListMap 的存取时间是log(N),和线程数几乎无关。也就是说在数据量一定的情况下,并发的线程越多,ConcurrentSkipListMap越能体现出他的优势。
- 在4线程1.6万数据的条件下,ConcurrentHashMap 存取速度是ConcurrentSkipListMap 的4倍左右。但ConcurrentSkipListMap有几个ConcurrentHashMap 不能比拟的优点:
在非多线程的情况下,应当尽量使用TreeMap。此外对于并发性相对较低的并行程序可以使用Collections.synchronizedSortedMap将TreeMap进行包装,也可以提供较好的效率。对于高并发程序,应当使用ConcurrentSkipListMap,能够提供更高的并发度。
所以在多线程程序中,如果需要对Map的键值进行排序时,请尽量使用ConcurrentSkipListMap,可能得到更好的并发度。 注意,调用ConcurrentSkipListMap的size时,由于多个线程可以同时对映射表进行操作,所以映射表需要遍历整个链表才能返回元素个数,这个操作是个O(log(n))的操作。