文章目录
- 1. HashMap可以存null,ConcurrentHashMap不可以,为什么?
- 2. ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?
- 3. ConcurrentHashMap 是如何保证线程安全的?
- 4. ConcurrentHashMap 的 size()方法是线程安全的吗?为什么
- 5. volatile保证可见性的原理
- 6. 请简述一下伪共享的概念以及如何避免
- 7. 如何解决ArrayList集合不安全问题?
- 8. 可重入锁(递归锁)
- 9. 公平锁与非公平锁
- 10. 自旋锁(spinlock)
- 11. 独占锁与共享锁
- 12. 死锁
- 13. 死锁的发生原因和怎么避免
- 14. 线程的优先级
- 15. 关于Synchronized了解多少
- 16. 谈谈你对 AQS 的理解
- 17. AQS 为什么采用双向链表
- 18. 如何解决HashSet集合不安全问题?
- 19. lock 和 synchronized 区别
- 20. 线程池如何知道一个线程的任务已经执行完成
- 21. 什么叫做阻塞队列的有界和无界
- 22. 能谈一下 CAS 机制吗?
- 23. 讲一下wait 和notify 这个为什么要在synchronized 代码块中?
- 24. 什么是守护线程,它有什么特点
- 25. ThreadLocal 是什么?它的实现原理
- 26. 基于数组的阻塞队列 ArrayBlockingQueue 原理
- 27. 请说一下 ReentrantLock 的实现原理?
- 28. 简述一下你对线程池的理解?
- 29. 如何中断一个正在运行的线程?
- 30. 为什么引入偏向锁、轻量级锁,介绍下升级流程
- 31. 说一下你对 CompletableFuture 的理解
- 32. 线程状态,BLOCKED 和 WAITING 有什么区别?
- 33. Thread 和Runnable 的区别
- 35. wait 和sleep 是否会触发锁的释放以及CPU 资源的释放?
- 36. 讲下线程池的线程回收
- 37. 如果一个线程两次调用 start(),会出现什么问题?
- 38. Java 官方提供了哪几种线程池,分别有什么特点?
- 39. 请你说一下你对 Happens-Before 的理解。
- 40. 线程池是如何实现线程复用的?
- 41. 可以说下阻塞队列被异步消费怎么保持顺序吗?
- 42. 当任务数超过线程池的核心线程数时,如何让它不进入队列,而是直接启用最大线程数
- 43. SimpleDateFormat 是线程安全的吗? 为什么?
- 44. 并行和并发有什么区别?
- 45. ThreadLocal 会出现内存泄漏吗?
- 46. 进程和线程的区别?
1. HashMap可以存null,ConcurrentHashMap不可以,为什么?
我们知道,ConcurrentHashMap在使用时,和HashMap有一个比较大的区别,那就是HashMap中,null可以作为键或者值都可以。而在ConcurrentHashMap中,key和value都不允许为null。
那么,为什么呢?为啥ConcurrentHashMap要设计成这样的呢?
关于这个问题,其实最有发言权的就是ConcurrentHashMap的作者——Doug Lea。
他自己曾经出面解释过这个问题,内容如下(原文地址已经打不开了,大家将就着看一下截图吧) :
主要意思就是说:
ConcurrentMap(如ConcurrentHashMap、ConcurrentSkipListMap)不允许使用null值的主要原因是,在非并发的Map中(如HashMap),是可以容忍模糊性(二义性)的,而在并发Map中是无法容忍的。
假如说,所有的Map都支持null的话,那么map.get(key)就可以返回null,但是,这时候就会存在一个不确定性,当你拿到null的时候,你是不知道他是因为本来就存了一个null进去还是说就是因为没找到而返回了null。
在HashMap中,因为它的设计就是给单线程用的,所以当我们map.get(key)返回null的时候,我们是可以通过map.contains(key)检查来进行检测的,如果它返回true,则认为是存了一个null,否则就是因为没找到而返回了null
。
但是,像ConcurrentHashMap,它是为并发而生的,它是要用在并发场景中
的,当我们map.get(key)返回null的时候,是没办法通过通过map.contains(key)检查来准确的检测,因为在检测过程中可能会被其他线程锁修改,而导致检测结果并不可靠
。
所以,为了让ConcurrentHashMap的语义更加准确,不存在二义性的问题,他就不支持null
。
2. ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?
- ConcurrentHashMap 在JDK1.8 中的存储结构,它是由
数组
、单向链表
、红黑树
组成。 - 当我们初始化一个ConcurrentHashMap 实例时,默认会初始化一个长度为16 的数组。由于ConcurrentHashMap 它的核心仍然是hash 表,所以必然会存在 hash 冲突问题。 ConcurrentHashMap 采用
链式寻址法
解决 hash 冲突。 - 当hash 冲突比较多的时候,会造成链表长度较长,这种情况会使得 ConcurrentHashMap 中数据元素的查询复杂度变成
O(n)
。因此在 JDK1.8 中,引入了红黑树的机制。 - 当数组长度大于 64 并且链表长度大于等于 8 的时候,单项链表就会转换为红黑树。另外,随着ConcurrentHashMap 的动态扩容,一旦链表长度小于 8,红黑树会退化成单向链表。
- ConcurrentHashMap 本质上是一个HashMap,因此功能和HashMap 一样,但是 ConcurrentHashMap 在HashMap 的基础上,提供了并发安全的实现。并发安全的主要实现是
通过对指定的 Node 节点加锁
,来保证数据更新的安全性
- ConcurrentHashMap 在性能方面做的优化
如何在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如cpu的三级缓存、mysql 的buffer_pool、Synchronized 的锁升级等等;
ConcurrentHashMap 也做了类似的优化,主要体现在以下几个方面:- 在JDK1.8 中,ConcurrentHashMap 锁的粒度是数组中的某一个节点,而在 JDK1.7,锁定的是Segment,锁的范围要更大,因此性能上会更低;
- 引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是
O(logn)
; - 当数组长度不够时,ConcurrentHashMap 需要对数组进行扩容,在扩容的实现上,
ConcurrentHashMap 引入了多线程并发扩容的机制
,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。
- ConcurrentHashMap 中有一个size()方法来获取总的元素个数,而在多线程并发场景中,在保证原子性的前提下来实现元素个数的累加,性能是非常低的;ConcurrentHashMap 在这个方面的优化主要体现在两个点:
当线程竞争不激烈时,直接采用CAS 来实现元素个数的原子递增
;- 如果线程竞争激烈,使用一个数组来维护元素个数,如果要增加总的元素个数,则直接从数组中随机选择一个,再通过 CAS 实现原子递增。它的核心思想是
引入了数组来实现对并发更新的负载
3. ConcurrentHashMap 是如何保证线程安全的?
3.1 JDK1.7 实现原理
首先,我们来看JDK 1.7 中ConcurrentHashMap 的底层结构,它基本延续了HashMap的设计,采用的是数组 加 链表的形式。和 HashMap 不同的是,ConcurrentHashMap中的数组设计 分为大数组Segment
和小数组HashEntry
,来着这张图。
大树组Segment 可以理解为一个数据
库,而每个数据库(Segment)中又有很多张表(HashEntry)
,每个 HashEntry 中又有很多条数据,这些数据是用链表连接的
。了解了ConcurrentHashMap 的基本结构设计,我们再来看它的线程安全实现,就比较简单了。
因为Segment 本身是基于ReentrantLock 重入锁实现的加锁和释放锁的操作,这样就能保证多个线程同时访问ConcurrentHashMap 时,同一时间只能有一个线程能够操作相应的节点,这样就保证了 ConcurrentHashMap 的线程安全
就是说ConcurrentHashMap 的线程安全是建立在Segment 加锁的基础上的,所以,我们称它为分段锁或者片段锁
3.2 JDK1.8 优化内容
在JDK1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组加链表的形式,所以在数据比较多情况下,因为要遍历整个链表,会降低访问性能。所以,JDK1.8 以后采用了数组 加 链表 加 红黑树的方式优化了 ConcurrentHashMap的实现,具体实现如图所示。
当链表长度大于 8,并且数组长度大于 64 时,链表就会升级为红黑树的结构。JDK 1.8中的ConcurrentHashMap 虽然保留了Segment 的定义,但这,仅仅是为了保证序列化时的兼容性,不再有任何结构上的用处了。
那在JDK 1.8 中ConcurrentHashMap 的源码是如何实现的呢?它主要是使用了 CAS加 volatile 或者 synchronized 的方式来保证线程安全。
我们可以从源码片段中看到,添加元素时首先会判断容器是否为空,
如果为空则使用 volatile 加 CAS 来初始化,
如果容器不为空,则根据存储的元素计算该位置是否为空。
如果根据存储的元素计算结果为空则利用 CAS 设置该节点;
如果根据存储的元素计算为空不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,
最后再判断是否需要转为红黑树。这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是 ConcurrentHashMap 通过对头结点加锁来保证线程安全的。
这样设计的好处是,使得锁的粒度相比Segment 来说更小了,发生 hash 冲突 和 加锁的频率也降低了,在并发场景下的操作性能也提高了。而且,当数据量比较大的时候,查询性能也得到了很大的提升。
3.3 总结
-
ConcurrentHashMap 在JDK 1.7 中使用的数组 加 链表的结构,其中数组分为两类,大树组Segment 和 小数组 HashEntry,而加锁是通过给Segment 添加
ReentrantLock 重入锁
来保证线程安全的。 -
ConcurrentHashMap 在JDK1.8 中使用的是数组 加 链表 加 红黑树的方式实现,它是通过
CAS
或者synchronized
来保证线程安全的,并且缩小了锁的粒度,查询 性能也更高。
4. ConcurrentHashMap 的 size()方法是线程安全的吗?为什么
ConcurrentHashMap 的size()方法是非线程安全的
也就是说,当有线程调用put 方法在添加元素的时候,其他线程在调用size()方法获取的元素个数和实际存储元素个数是不一致的。
原因是size()方法是一个非同步方法,put()方法和 size()方法并没有实现同步锁。
- put()方法的实现逻辑是:在 hash 表上添加或者修改某个元素,然后再对总的元素个数进行累加。
其中,线程的安全性仅仅局限在hash 表数组粒度的锁同步,避免同一个节点出现数据竞争带来线程安全问题。
数组元素个数的累加方式用到了两个方案:- 当线程竞争不激烈的时候,直接用 cas 的方式对一个 long 类型的变量做原子递增。
- 当线程竞争比较激烈的时候,使用一个
CounterCell 数组
,用分而治之的思想减少多线程竞争
,从而实现元素个数的原子累加。
- size()方法的逻辑就是遍历 CounterCell 数组中的每个 value 值进行累加,再加上 baseCount,汇总得到一个结果。
所以很明显,size()方法得到的数据和真实数据必然是不一致的。
因此从size()方法本身来看,它的整个计算过程是线程安全的,因为这里用到了CAS的方式解决了并发更新问题。但是站在ConcurrentHashMap 全局角度来看,put()方法和size()方法之间的数据是不一致的,因此也就不是线程安全的。
之所以不像HashTable 那样,直接在方法级别加同步锁。在我看来有两个考虑点。
- 直接在size()方法加锁,就会造成数据写入的并发冲突,对性能造成影响,当然有些朋友会说可以加读写锁,但是同样会造成 put 方法锁的范围扩大,性能影响极大!
- ConcurrentHashMap 并发集合中,对于size()数量的一致性需求并不大,并发集合更多的是去保证数据存储的安全性。
5. volatile保证可见性的原理
Java中的volatile关键字是通过调用C语言实现的,而在更底层的实现上,即汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令来保证工作内存中读取到的数据是主内存中最新的数据。
具体的实现原理是在硬件层面上通过:
MESI缓存一致性协议
:多个cpu从主内存读取数据到高速缓存中,如果其中一个cpu修改了数据
,会通过总线立即回写到主内存中,其他cpu会通过总线嗅探机制
感知到缓存中数据的变化并将工作内存中的数据失效,再去读取主内存中的数据。IA32架构软件开发者手册对lock前缀指令的解释:
- 会将当前处理器缓存行的数据立即回写到系统内存中,
- 这个写回内存的操作会引起其他cpu里缓存了该内存地址的数据失效(MESI协议)
-
有序性
主要通过对volatile修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性的。 -
可见性
主要是通过Lock前缀指令 + MESI缓存一致性协议来实现的
。对volatiile修饰的变量执行写操作时,JVM会发送一个Lock前缀指令给CPU,CPU在执行完写操作后,会立即将新值刷新到内存,同时因为MESI缓存一致性协议,其他各个CPU都会对总线嗅探,看自己本地缓存中的数据是否被别人修改,如果发现修改了,会把自己本地缓存的数据过期掉。然后这个CPU里的线程在读取改变量时,就会从主内存里加载最新的值了,这样就保证了可见性。
总结:
volatile 关键字有两个作用。
- 可以保证在多线程环境下共享变量的可见性。
- 通过增加内存屏障防止多个指令之间的重排序
这个可见性问题,我认为本质上是由几个方面造成的:
- CPU 层面的高速缓存,在 CPU 里面设计了三级缓存去解决 CPU 运算效率和内存IO 效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行的情况下,缓存一致性就会导致可见性问题;
所以,对于增加了 volatile 关键字修饰的共享变量,JVM 虚拟机会自动增加一个#Lock汇编指令,这个指令会根据 CPU 型号自动添加总线锁
或缓存锁
我简单说一下这两种锁,
总线锁是锁定了 CPU 的前端总线
,从而导致在同一时刻只能有一个线程去和内存通信
,这样就避免了多线程并发造成的可见性。缓存锁
是对总线锁的优化,因为总线锁导致了CPU 的使用效率大幅度下降,所以缓存锁只针对CPU 三级缓存中的目标数据加锁,缓存锁是使用 MESI 缓存一致性来实现
的
指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。
指令重排序本质上是一种性能优化的手段,它来自于几个方面:
- CPU 层面,针对MESI 协议的更进一步优化去提升 CPU 的利用率,引入了 StoreBuffer 机制,而这一种优化机制会导致 CPU 的乱序执行。当然为了避免这样的问题,CPU 提供了
内存屏障指令
,上层应用可以在合适的地方插入内存屏障来避免CPU 指令重排序问题。 - 编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令进行合理的重排序优化来提升性能。
所以,如果对共享变量增加了 volatile 关键字,那么在编译器层面,就不会去触发编译器优化,同时再 JVM 里面,会插入内存屏障指令来避免重排序问题。当然,除了volatile 以外,从JDK5 开始,JMM 就使用了一种Happens-Before 模型
描述多线程之间的内存可见性问题。
如果两个操作之间具备 Happens-Before 关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加 volatile 关键字来提供可见性保障。
5.1 什么是JMM?
JMM(java内存模型java Memory Model),本身是一种抽象的概念并不真实存在,它的描述是一组规则或规范,通过这组规范定义了程序中各个变量(包括实列字段,静态字段和构成数组对象的元素)的访问方式
5.1.1 JMM关于同步的规定?
-
1.线程解锁前,必须把共享变量的值刷回主内存
-
2.线程加锁前,必须读取主内存中最新值到自己的工作内存
-
3.加锁解锁是同一把锁
由于JVM运行程序实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(些地方称为栈空间),工作内存是每个线程的私空间,而java内存模型中规定所的变量都存储在主内存,主内存是共享区域,所线程都可以访问,但线程对变量的操作(读赋值等)都必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存,然后对变量副本拷贝,因此不同的线程间无法访问对方的工作内存,
线程间的通信(传值)必须通过主内存来完成
。
JMM要求保证 可见性,原子性,有序性(禁止指令重排)
并不是所都遵循JMM
1.可见性
通过前面对JMM的介绍,我们知道各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的.
这就可能存在一个线程AAA修改了共享变量X的值还未写回主内存中时 ,另外一个线程BBB又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.
2.原子性
3.有序性
计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一把分为以下3中
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致.
处理器在进行重新排序是必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测
6. 请简述一下伪共享的概念以及如何避免
首先,计算机工程师为了提高 CPU 的利用率,平衡CPU 和内存之间的速度差异,在 CPU 里面设计了三级缓存
。
CPU 在向内存发起IO 操作的时候,一次性会读取 64 个字节的数据作为一个缓存行,缓存到CPU 的高速缓存里面。
在Java 中一个long 类型是 8 个字节,意味着一个缓存行可以存储 8 个long 类型的变量。
这个设计是基于空间局部性原理
来实现的,也就是说,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
所以缓存行
的设计对于CPU 来说,可以有效的减少和内存的交互次数,从而避免了 CPU的IO 等待,以提升CPU 的利用率。
正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能
,这就是伪共享
的问题。
(如图)像这样一种情况,CPU0 上运行的线程想要更新变量 X、CPU1 上的线程想要更新变量Y,而X/Y/Z 都在同一个缓存行里面。每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。
一旦运行在某个 CPU 上的线程获得了所有权并执行了修改,就会导致其他 CPU 中的缓存行失效。这就是伪共享问题的原理。
因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响;
这个问题的解决办法有两个:
使用对齐填充
,因为一个缓存行大小是 64 个字节,如果读取的目标数据小于 64个字节,可以增加一些无意义的成员变量来填充。- 在Java8 里面,提供了
@Contented 注解
,它也是通过缓存行填充来解决伪共享问题的,被@Contented 注解声明的类或者字段,会被加载到独立的缓存行上。
在Netty 里面,有大量用到对齐填充
的方式来避免伪共享问题。
7. 如何解决ArrayList集合不安全问题?
7.1 解决方案1:Vector
Vector 所有方法都加锁
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
不推荐,Vector 太老 1.0就出现了 arrayList出现是1.2 1.Vector使用同步方法实现,Synchronized同步方法实现 太重
7.2 解决方案2:Collections.synchronizedList(ArrayList);
Collections.synchronizedList(ArrayList);
public boolean add(T e) {
synchronized(mutex) {
return backingList.add(e);
}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
同样不推荐,synchronizedList使用同步代码块实现
在SynchronizedMap内部维护了一个普通的对象Map,还有排斥锁mutex,调用该方法时就需要传入一个map.有两个构造器
,如果你也传入了mutex,则将mutex参数赋值给传入的对象,如果没有则为this,则调用synchronized对象,创建完
SynchronizedMap之后,就会大多数方法里面使用同步代码块上锁
7.3 解决方案3:new CopyOnWriteArrayList<>();
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
public E get(int index) {
return get(getArray(), index);
}
推荐
选择使用CopyOnWriteArrayList 写时复制,CopyOnWriteArrayList容器即写时复制的容器,往一个容器当中添加元素的时候,不直接往当前容器的Object[]添加,而是先将当前容器Copy,复制出一份新的容器 object[] newelements,往新的容器中添加元素,添加完,再将原来的容器指向新的容器,这样的好处是并发的读,,读操作不需要加锁,这是一种读写分离思想
8. 可重入锁(递归锁)
可重入锁(也叫做递归锁)指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁也即是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。
ReentrantLock/Synchronized就是经典的可重入锁,可重入锁最大的作用就是避免死锁
代码举列:
public sync void method(){
method2();
}
public sync void method2(){}
指的是同一个线程外层函数获得锁之后,内层递归函数仍然可以获取该锁的代码
看下面这串代码:这样写法对不对,编译通过不,运行会不会报错
public void get(){
lock.lock();
locak.lock();
try{}finally{
lock.unlock();
lock.unlock();
}
}
答案是: 要配对,写几个lock就要几个unlock,多写几对程序编译通过,运行也不会报错,正常
但是如果写2个lock但是只一个unlock,编译通过,运行也没问题,但是程序会一直卡着运行不结束;
9. 公平锁与非公平锁
- 公平锁
是指多个线程照申请锁的顺序来获取锁类似排队打饭 先来后到 - 非公平锁
是指在多线程获取锁的顺序并不是照申请锁的顺序,可能后申请的线程比先申请的线程优先获取到锁,在高并发的情况下,可能造成优先级反转或者饥饿现象
Java ReentrantLock而言, 通过构造函数指定该锁是否是公平锁 默认是非公平锁 非公平锁的优点在于吞吐量比公平锁大.
对于synchronized而言 也是一种非公平锁.
10. 自旋锁(spinlock)
是指尝试获取锁的线程不会立即阻塞,而是采肛循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
自旋锁demo
import java.security.PublicKey;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* @version V1.0.0
* @author: WangQingLong
* @date: 2020/8/1 9:05
* @description: 自旋锁 使用循环代替阻塞 优点:减少上下文切换消耗 缺点:CPU蹦高,
*/
public class SpinDemo {
//原子引用线程
//如果数据类型,不赋值默认为0 如果是引用类型,默认为null
AtomicReference<Thread> a = new AtomicReference<>();
public void myLock(){
Thread thread = Thread.currentThread();
System.out.println("Mylock当前线程为"+thread.getName()+"进来了");
while (!a.compareAndSet(null,thread)){
}
System.out.println("Mylock当前线程为"+thread.getName()+"进来了拿到锁了");
}
public void myUnlock(){
Thread thread = Thread.currentThread();
a.compareAndSet(thread,null);
System.out.println("MyUnlock当前线程为"+thread.getName()+"释放了锁");
}
public static void main(String[] args) {
SpinDemo spinDemo = new SpinDemo();
new Thread(()->{
spinDemo.myLock();
try {
TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) { e.printStackTrace();}
spinDemo.myUnlock();
},"A").start();
try {TimeUnit.SECONDS.sleep(1);} catch (InterruptedException e) { e.printStackTrace();}
new Thread(()->{
spinDemo.myLock();
spinDemo.myUnlock();
},"B").start();
}
}
11. 独占锁与共享锁
- 独占锁:指该锁一次只能被一个线程所持有。对ReentrantLock和Synchronized而言都是独占锁
- 共享锁:指该锁可被多个线程所持有。对ReentrantReadWriteLock其读锁是共享锁,其写锁是独占锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
demo
/**
* 资源类
*/
class MyCaChe {
/**
* 保证可见性
*/
private volatile Map<String, Object> map = new HashMap<>();
private ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
/**
* 写
*
* @param key
* @param value
*/
public void put(String key, Object value) {
reentrantReadWriteLock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t正在写入" + key);
//模拟网络延时
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
map.put(key, value);
System.out.println(Thread.currentThread().getName() + "\t正在完成");
} finally {
reentrantReadWriteLock.writeLock().unlock();
}
}
/**
* 读
*
* @param key
*/
public void get(String key) {
reentrantReadWriteLock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "\t正在读取");
//模拟网络延时
try {
TimeUnit.MICROSECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object result = map.get(key);
System.out.println(Thread.currentThread().getName() + "\t正在完成" + result);
} finally {
reentrantReadWriteLock.readLock().unlock();
}
}
public void clearCaChe() {
map.clear();
}
}
/**
* Description:
* 多个线程同时操作 一个资源类没任何问题 所以为了满足并发量
* 读取共享资源应该可以同时进行
* 但是
* 如果一个线程想去写共享资源来 就不应该其他线程可以对资源进行读或写
* <p>
* 小总结:
* 读 读能共存
* 读 写不能共存
* 写 写不能共存
* 写操作 原子+独占 整个过程必须是一个完成的统一整体 中间不允许被分割 被打断
*
* @author wql
* @date 2019-04-13 0:45
**/
public class ReadWriteLockDemo {
public static void main(String[] args) {
MyCaChe myCaChe = new MyCaChe();
for (int i = 1; i <= 5; i++) {
final int temp = i;
new Thread(() -> {
myCaChe.put(temp + "", temp);
}, String.valueOf(i)).start();
}
for (int i = 1; i <= 5; i++) {
int finalI = i;
new Thread(() -> {
myCaChe.get(finalI + "");
}, String.valueOf(i)).start();
}
}
}
12. 死锁
12.1. 简述
死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现场,若无外力干涉那它们都将无法继续推进下去,如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则会因为争夺有限的资源而陷入死锁;
12.2. 死锁出现原因
- 系统资源不足
- 进程运行推进的顺序不合适
- 资源分配不当
12.3 Demo
class HoldThread implements Runnable {
private String lockA;
private String lockB;
public HoldThread(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}
@Override
public void run() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "\t 自己持锁" + lockA + "尝试获得" + lockB);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "\t 自己持锁" + lockB + "尝试获得" + lockA);
}
}
}
}
/**
* Description:
* 死锁是指两个或者以上的进程在执行过程中,
* 因争夺资源而造成的一种相互等待的现象,
* 若无外力干涉那他们都将无法推进下去
*
* @author wql
* @date 2019-04-14 0:05
**/
public class DeadLockDemo {
public static void main(String[] args) {
String lockA = "lockA";
String lockB = "lockB";
new Thread(new HoldThread(lockA, lockB), "threadAAA").start();
new Thread(new HoldThread(lockB, lockA), "threadBBB").start();
}
}
12.4 找死锁原因
jps命令定位进程编号
jstack找到死锁查看
13. 死锁的发生原因和怎么避免
死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。
如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。
导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁。
- 互斥条件,共享资源 X 和 Y 只能被一个线程占用;
- 请求和保持条件,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占条件,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待条件,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
导致死锁之后,只能通过人工干预来解决,比如重启服务,或者杀掉某个线程。所以,只能在写代码的时候,去规避可能出现的死锁问题。
按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来破坏:
- 对于“请求和保持”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
- 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
14. 线程的优先级
线程的优先级(Priority)决定了线程获得CPU运行的机会,优先级越髙获得的运行机 会越大,优先级越低获得的机会越小。
Java的线程有10个级别(准确地说是11个级别,级 别为〇的线程是JVM的,应用程序不能设置该级别)
事实上,不同的操作系统线程优先级是不相同的
,Windows有7个优先 级,Linux有140个优先级,Freebsd则有255个(此处指的是优先级总数,不同操作系统有 不同的分类,如中断级线程、操作系统级等,各个操作系统具体用户可用的线程数量也不相同)。
Java是跨平台的系统,需要把这个10个优先级映射成不同操作系统的优先级,于是界定了 Java的优先级只是代表抢占CPU的机会大小,优先级越髙,抢占CPU的机会越大,被优先执行的可能性越高,优先级相差不大,则抢占CPU的机会差别也不大,这就是导致了 优先级为9的线程可能比优先级为10的线程先运行。
Java的缔造者们也察觉到了线程优先问题,于是在Thread类中设置了三个优先级,此 意就是告诉开发者,建议使用优先级常量,而不是1到10随机的数字。常量代码如下:
public class Thread implementsRunnable {
//最低优先级
public final static int MIN一PRIORITY = 1;
//普通优先级,默认值
public final static int NORM一PRIORITY = 5;
//最高优先级
public final static int MAX一PRIORITY = 10;
}
在编码时直接使用这些优先级常量,可以说在大部分情况下max_priority的线程会 比NORM_PRIORITY的线程先运行,但是不能认为必然会先运行,不能把这个优先级做为核心业务的必然条件,Java无法保证优先级高肯定会先执行,只能保证髙优先级有更多的执 行机会。因此,建议在开发时只使用此三类优先级,没有必要使用其他7个数字,这样也可以保证在不同的操作系统上优先级的表现基本相同。
明白了这个问题,那可能有读者要问了:如果优先级相同呢?这很好办,也是由操作系统决定的,基本上是按照FIFO原则(先入先出,First Input First Output),但也是不能完全保证。 注意线程优先级推荐使用MIN_PRIORITY、NORM_PRIORITY、MAX_PRIORITY三个 级别,不建议使用其他7个数字。
15. 关于Synchronized了解多少
15.1 关于对象
java内的对象由三部分组成 对象头(内存布局)+实列数据+对其填充数据(可有可无,看前2个之和是否是8的倍数)
对象头由2部分组成 Mark Word+klass pointer
查看地址: https://openjdk.java.net/groups/hotspot/docs/HotSpotGlossary.html
mark word:
The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.
每个gc管理的堆对象开头的公共结构(每个oop都指向一个对象头)包括堆对象的布局,类型,GC状态,同步状态和标识哈希码的基本信息,java对象和Jvm内部都一个通用的对象头格式
在使用Synchronized的时候,java对象几种状态?
5种: 无锁,偏向锁,轻量级锁,重量级锁,GC标记
对象头在64bit的jvm当中,占12的字节,共96bit, 其中 Mark word 占64it 存的东西不固定
Kclass pointer 占32bit 如果的地方说是64bit,也对,说明没开启指针压缩 在32bit的jvm当中,占8的字节,共64bit
其中 Mark word 占32it 存的东西不固定 Kclass pointer 占32bit
15.2. 工具JOL
<dependencies>
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
</dependencies>
public class abc {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance("5").toPrintable());
}
}
15.3. 锁升级过程
无锁 - 偏向锁 - 轻量级锁 (自旋锁,自适应自旋)- 重量级锁
synchronized优化的过程和markword息息相关
用markword中最低的三位代表锁状态 其中1位是偏向锁位 两位是普通锁位
Object o = new Object()锁 = 0 01 无锁态
o.hashCode()001 + hashcode
Object o = new Object(); 001 无锁态
00000001 10101101 00110100 00110110
01011001 00000000 00000000 00000000
o.hashCode() 001+hashCode
little endian big endian
00000000 00000000 00000000 01011001 00110110 00110100 10101101 00000000
1)Little-endian:将低序字节存储在起始地址(低位编址)
2)Big-endian:将高序字节存储在起始地址(高位编址)
3.=========默认synchronized(o) 00 -> 轻量级锁默认情况 偏向锁个时延,默认是4秒 why? 因为JVM虚拟机自己一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。
-XX:BiasedLockingStartupDelay=0
4.==============如果设定上述参数
new Object () - > 101 偏向锁 ->线程ID为0 -> Anonymous BiasedLock
打开偏向锁,new出来的对象,默认就是一个可偏向匿名对象101
5.==============如果线程上锁
上偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程
批量重偏向:当一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。
批量撤销:在多线程竞争剧烈的情况下,使用偏向锁将会降低效率,于是乎产生了批量撤销机制。
通过JVM的默认参数值,找一找批量重偏向和批量撤销的阈值。
设置JVM参数-XX:+PrintFlagsFinal,在项目启动时即可输出JVM的默认参数值
intx BiasedLockingBulkRebiasThreshold = 20 默认偏向锁批量重偏向阈值
intx BiasedLockingBulkRevokeThreshold = 40 默认偏向锁批量撤销阈值
当然我们可以通过
-XX:BiasedLockingBulkRebiasThreshold 和
-XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值
1、批量重偏向和批量撤销是针对类的优化,和对象无关。
2、偏向锁重偏向一次之后不可再次重偏向。
3、当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利
6. 如果线程竞争
撤销偏向锁,升级轻量级锁
线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁
7. 如果竞争加剧
竞争加剧:线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制
升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间
偏向锁 - markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。hashCode备份在线程栈上 线程销毁,锁降级为无锁
争用 - 锁升级为轻量级锁 - 每个线程自己的LockRecord在自己的线程栈上,用CAS去争用markword的LR的指针,指针指向哪个线程的LR,哪个线程就拥锁
自旋超过10次,升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU-XX:PreBlockSpin
锁消除 lock eliminate
public void add(String str1,String str2){
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。
锁粗化 lock coarsening
public String test(String str){
int i = 0;
StringBuffer sb = new StringBuffer():
while(i < 100){
sb.append(str);
i++;
}
return sb.toString():
}
JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。
在高争用 高耗时的环境下synchronized效率更高
在低争用 低耗时的环境下CAS效率更高
synchronized到重量级之后是等待队列(不消耗CPU)
CAS(等待期间消耗CPU)
一切以实测为准
15.4 批量重偏向
public static void main(String[] args) {
public static void main(String[] args) throws Exception {
//延时产生可偏向对象
Thread.sleep(5000);
//创造100个偏向线程t1的偏向锁
List<A> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i <100 ; i++) {
A a = new A();
synchronized (a){
listA.add(a);
}
}
try {
//为了防止JVM线程复用,在创建完对象后,保持线程t1状态为存活
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
//睡眠3s钟保证线程t1创建对象完成
Thread.sleep(3000);
out.println("打印t1线程,list中第20个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(19)).toPrintable()));
//创建线程t2竞争线程t1中已经退出同步块的锁
Thread t2 = new Thread(() -> {
//这里面只循环了30次!!!
for (int i = 0; i < 30; i++) {
A a =listA.get(i);
synchronized (a){
//分别打印第19次和第20次偏向锁重偏向结果
if(i==18||i==19){
out.println("第"+ ( i + 1) + "次偏向结果");
out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
Thread.sleep(3000);
out.println("打印list中第11个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(10)).toPrintable()));
out.println("打印list中第26个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(25)).toPrintable()));
out.println("打印list中第41个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(40)).toPrintable()));
}
}
15.5 批量撤销
public static void main(String[] args) throws Exception {
Thread.sleep(5000);
List<A> listA = new ArrayList<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i <100 ; i++) {
A a = new A();
synchronized (a){
listA.add(a);
}
}
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
Thread.sleep(3000);
Thread t2 = new Thread(() -> {
//这里循环了40次。达到了批量撤销的阈值
for (int i = 0; i < 40; i++) {
A a =listA.get(i);
synchronized (a){
}
}
try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t2.start();
//———————————分割线,前面代码不再赘述——————————————————————————————————————————
Thread.sleep(3000);
out.println("打印list中第11个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(10)).toPrintable()));
out.println("打印list中第26个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(25)).toPrintable()));
out.println("打印list中第90个对象的对象头:");
out.println((ClassLayout.parseInstance(listA.get(89)).toPrintable()));
Thread t3 = new Thread(() -> {
for (int i = 20; i < 40; i++) {
A a =listA.get(i);
synchronized (a){
if(i==20||i==22){
out.println("thread3 第"+ i + "次");
out.println((ClassLayout.parseInstance(a).toPrintable()));
}
}
}
});
t3.start();
Thread.sleep(10000);
out.println("重新输出新实例A");
out.println((ClassLayout.parseInstance(new A()).toPrintable()));
}
16. 谈谈你对 AQS 的理解
AQS 全名 AbstractQueuedSynchronizer,意为抽象队列同步器,JUC(java.util.concurrent 包)下面的 Lock 和其他一些并发工具类都是基于它来实现的。AQS 维护了一个 volatile 的 state
和一个 CLH(FIFO)双向队列
AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。
AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改;
AQS 是多线程同步器,它是 J.U.C 包中多个组件的底层实现,如Lock、 CountDownLatch、Semaphore 等都用到了AQS.
从本质上来说,AQS 提供了两种锁机制,分别是排它锁,和 共享锁。
- 排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如 Lock 中的ReentrantLock 重入锁实现就是用到了 AQS 中的排它锁功能。
- 共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如 CountDownLatch 和Semaphore 都是用到了AQS 中的共享锁功能。
17. AQS 为什么采用双向链表
第一个方面,双向链表的优势:
- 双向链表提供了双向指针,可以在任何一个节点方便向前或向后进行遍历,这种对于有反向遍历需求的场景来说非常有用。
- 双向链表可以在任意节点位置实现数据的插入和删除,并且这些操作的时间复杂度都是 O(1),不受链表长度的影响。这对于需要频繁对链表进行增删操作的场景非常有用。
第二个方面,说一下 AQS 采用双向链表的原因
- 存储在双向链表中的线程,有可能这个线程出现异常不再需要竞争锁,所以需要把这些异常节点从链表中删除,而删除操作需要找到这个节点的前驱结点,如果不采用双向链表,就必须要从头节点开始遍历,时间复杂度就变成了 O(n)。
- 新加入到链表中的线程,在进入到阻塞状态之前,需要判断前驱节点的状态,只有前驱节点是Sign 状态的时候才会让当前线程阻塞,所以这里也会涉及到前驱节点的查找,采用双向链表能够更好的提升查找效率;
- 线程在加入到链表中后,会通过自旋的方式去尝试竞争锁来提升性能,在自旋竞争锁的时候为了保证锁竞争的公平性,需要先判断当前线程所在节点的前驱节点是否是头节点。这个判断也需要获取当前节点的前驱节点,同样采用双向链表能提高查找效率。
总而言之,采用单向链表不支持双向遍历,而AQS 中存在很多需要双向遍历的场景来提升线程阻塞和唤醒的效率。
18. 如何解决HashSet集合不安全问题?
使用CopyOnWriterSet即可
,HashSet 底层是HashMap ,只不过它的Value是写死的Present ,是一个Object对象
CopyOnWriterSet 底层也是CopyOnWriterArrayList
19. lock 和 synchronized 区别
- 从功能角度来看,Lock 和Synchronized 都是Java 中用来解决线程安全问题的工具。
- 从特性来看
Synchronized 是Java 中的同步关键字
,Lock 是J.U.C 包中提供的接口,这个接口有很多实现类,其中就包括 ReentrantLock 重入锁;- Synchronized 可以通过两种方式来控制锁的粒度;
- 一种是把synchronized 关键字修饰在方法层面;
- 另一种是修饰在代码块上,并且我们可以通过 Synchronized 加锁对象的声明周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的声明周期;
Lock 锁的粒度是通过它里面提供的 lock()和 unlock()方法决定的,包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于 Lock 实例的生命周期
;
- Lock 比Synchronized 的灵活性更高,Lock 可以自主决定什么时候加锁,什么时候释放锁,只需要调用 lock()和 unlock()这两个方法就行,同时Lock 还提供了非阻塞的竞争锁方法 tryLock()方法,这个方法通过返回true/false 来告诉当前线程是否已经有其他线程正在使用锁。
- Synchronized 由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外, Synchronized 锁的释放是被动的,就是当 Synchronized 同步代码块执行完以后或者代码出现异常时才会释放。
- Lock 提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。 Synchronized 只提供了一种非公平锁的实现。
- 从性能方面来看
- Synchronized 和Lock 在性能方面相差不大,在实现上会有一些区别,Synchronized 引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而 Lock 中则用到了自旋锁的方式来实现性能优化
20. 线程池如何知道一个线程的任务已经执行完成
从两个方面来回答:
- 在线程池内部,当我们把一个任务丢给线程池去执行,线程池会调度工作线程来执行这个任务的run 方法,run 方法正常结束,也就意味着任务完成了;
- 所以线程池中的工作线程是通过同步调用任务的
run()
方法并且等待再去统计任务的完成数量。方法返回后再去统计任务的完成数量。
- 所以线程池中的工作线程是通过同步调用任务的
- 如果想在线程池外部去获得线程池内部任务的执行状态,有几种方法可以实现
- a.线程池提供了一个 isTerminated()方法,可以判断线程池的运行状态,我们可以循环判断 isTerminated()方法的返回结果来了解线程池的运行状态,一旦线程池的运行状态是 Terminated,意味着线程池中的所有任务都已经执行完了。想要通过这个方法获取状态的前提是,程序中主动调用了线程池的方法。在实际业务中,一般不会主动去关闭线程池,因此这个方法在实用性和灵活性方面都不是很好。
- b.在线程池中,有一个 submit()方法,它提供了一个 Future 的返回值,我们通过 Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,
future.get()
方法会一直阻塞,直到任务执行结束。因此,只要
方法正常返回,也就意味着传入到线程池中的任务已经执行完成了! - c.可以引入一个
CountDownLatch
计数器,它可以通过初始化指定一个计数器进行倒计时,其中有两个方法分别是 await()阻塞线程,以及进行倒计时,一旦倒计时归零,所以被阻塞在 await()方法的线程都会被释放。
基于这个问题,我简单总结一下,不管是线程池内部还是外部,要想知道线程是否执行结束,我们必须要获取线程执行结束后的状态,而线程本身没有返回值,所以只能通过阻塞-唤醒的方式来实现,future.get 和CountDownLatch 都是这样一个原理。
21. 什么叫做阻塞队列的有界和无界
阻塞队列,是一种特殊的队列,它在普通队列的基础上提供了两个附加功能;
- a.当队列为空的时候,获取队列中元素的消费者线程会被阻塞,同时唤醒生产者线程。
- b.当队列满了的时候,向队列中添加元素的生产者线程被阻塞,同时唤醒消费者线程。
- 其中,阻塞队列中能够容纳的元素个数,通常情况下是有界的,比如我们实例化一个 ArrayBlockingList,可以在构造方法中传入一个整形的数字,表示这个基于数组的阻塞队列中能够容纳的元素个数。这种就是有界队列。
- 而无界队列,就是没有设置固定大小的队列,不过它并不是像我们理解的那种元素没有任何限制,而是它的元素存储量很大,像LinkedBlockingQueue,它的默认队列长度是Integer.Max_Value,所以我们感知不到它的长度限制。
无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎无限制的添加任务,容易导致内存溢出的问题!
22. 能谈一下 CAS 机制吗?
CAS 是Java 中Unsafe 类里面的方法,它的全称是 CompareAndSwap,比较并交换的意思
。
它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。
举例:
比如说有这样一个场景(如图),有一个成员变量 state,默认值是 0,定义了一个方法 doSomething(),这个方法的逻辑是,判断 state 是否为 0 ,如果为 0,就修改成 1。
这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read - Write 的操作。一般情况下,我们会在 doSomething()这个方法上加同步锁来解决原子性问题。但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用CAS机制来进行优化
在doSomething()方法中,我们调用了 unsafe 类中的compareAndSwapInt()方法来达到同样的目的,这个方法有四个参数,分别是:当前对象实例、成员变量 state 在内存地址中的偏移量、预期值 0、期望更改之后的值 1。
CAS 机制会比较state 内存地址偏移量对应的值和传入的预期值0 是否相等,如果相等,就直接修改内存地址中 state 的值为 1.否则,返回false,表示修改失败,而这个过程是原子的,不会存在线程安全问题。
CompareAndSwap 是一个native 方法
,实际上它最终还是会面临同样的问题,就是先从内存地址中读取 state 的值,然后去比较,最后再修改。这个过程不管是在什么层面上实现,都会存在原子性问题。
所以呢,CompareAndSwap 的底层实现中,在多核 CPU 环境下,会增加一个Lock指令对缓存或者总线加锁
,从而保证比较并替换这两个指令的原子性。
CAS 主要用在并发场景中,比较典型的使用场景有两个。
- 第一个是J.U.C 里面Atomic 的原子实现,比如
AtomicInteger
,AtomicLong
。 - 第二个是实现多线程对共享资源竞争的互斥性质,比如在 AQS、 ConcurrentHashMap、ConcurrentLinkedQueue 等都有用到。
23. 讲一下wait 和notify 这个为什么要在synchronized 代码块中?
- wait 和notify 用来实现多线程之间的协调,wait 表示让线程进入到阻塞状态, notify 表示让阻塞的线程唤醒。
- wait 和notify 必然是成对出现的,如果一个线程被wait()方法阻塞,那么必然需要另外一个线程通过 notify()方法来唤醒这个被阻塞的线程,从而实现多线程之间的通信。
在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变量的方法来实现
,也就是线程 t1 修改共享变量s,线程t2 获取修改后的共享变量s,从而完成数据通信
但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在这种情况下,线程t2 在访问共享变量 s 之前,必须要知道线程 t1 已经修改过了共享变量s,否则就需要等待。同时,线程t1 修改过了共享变量S 之后,还需要通知在等待中的线程 t2。所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线程在什么条件下等待,什么条件下唤醒。- 而Synchronized 同步关键字就可以实现这样一个互斥条件,也就是在通过共享变量来实现多个线程通信的场景里面,参与通信的线程必须要竞争到这个共享变量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之前的通信。
- 所以这也是为什么wait/notify 需要放在Synchronized 同步代码块中的原因,有了Synchronized 同步锁,就可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。
- 另外,为了避免 wait/notify 的错误使用,jdk 强制要求把wait/notify 写在同步代码块里面,否则会抛出 IllegalMonitorStateException
24. 什么是守护线程,它有什么特点
简单来说,守护线程就是一种后台服务线程,他和我们在 Java 里面创建的用户线程是一模一样的。
守护线程和用户线程的区别有几个点,这几个点也是守护线程本身的特性:
- 在线程创建方面,对于守护线程,我们需要主动调用 setDaemon()并且设置成 true。
- 我们知道,一个 Java 进程中,只要有任何一个用户线程还在运行,那么这个 java进程就不会结束,否则,这个程序才会终止。
注意,Java 进程的终止与否,只和用户线程有关
。如果当前还有守护线程正在运行,也不会阻止Java 程序的终止。
因此,守护线程的生命周期依赖于用户线程
。
举个例子,JVM 垃圾回收线程就是一个典型的守护线程,它存在的意义是不断的处理用户线程运行过程中产生的内存垃圾。一旦用户线程全部结束了,那垃圾回收线程也就没有存在的意义了。由于守护线程的特性,所以它它适合用在一些后台的通用服务场景里面。
但是守护线程不能用在线程池或者一些IO 任务的场景里面
,因为一旦 JVM 退出之后,守护线程也会直接退出。
就会可能导致任务没有执行完或者资源没有正确释放
的问题。
25. ThreadLocal 是什么?它的实现原理
- ThreadLocal 是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
- 在多线程访问共享变量的场景中(出现下面第一个图),一般的解决办法是对共享变量加锁(出现下面第二个图),从而保证在同一时刻只有一个线程能够对共享变量进行更新,并且基于
Happens-Before 规
则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。
- 但是加锁会带来性能的下降,所以 ThreadLocal 用了一种
空间换时间
的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。
ThreadLocal 的具体实现原理是,在 Thread 类里面有一个成员变量 ThreadLocalMap,它专门来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个 ThreadLocalMap 里面进行变更,不会影响全局共享变量的值。
26. 基于数组的阻塞队列 ArrayBlockingQueue 原理
阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作:
- 在队列为空的时候,获取元素的线程会等待队列变为非空;
- 当队列满时,存储元素的线程会等待队列可用;
- 由于阻塞队列的特性,可以非常容易实现生产者消费者模型,也就是生产者只需要关心数据的生产,消费者只需要关注数据的消费,所以如果队列满了,生产者就等待,同样,队列空了,消费者也需要等待。
- 要实现这样的一个阻塞队列,需要用到两个关键的技术,队列元素的存储、以及线程阻塞和唤醒。
- 而ArrayBlockingQueue 是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue 用到了循环数组。
- 由于阻塞队列的特性,可以非常容易实现生产者消费者模型,也就是生产者只需要关心数据的生产,消费者只需要关注数据的消费,所以如果队列满了,生产者就等待,同样,队列空了,消费者也需要等待。
- 要实现这样的一个阻塞队列,需要用到两个关键的技术,队列元素的存储、以及线程阻塞和唤醒。
- 而ArrayBlockingQueue 是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue 用到了循环数组。
27. 请说一下 ReentrantLock 的实现原理?
首先,ReentrantLock 是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。
它的核心特性有几个:
1.它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
2.它支持公平和非公平特性
3.它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是 lock()和tryLock()。
然后,ReentrantLock 的底层实现有几个非常关键的技术
- 锁的竞争,ReentrantLock 是通过互斥变量,使用 CAS 机制来实现的。
- 没有竞争到锁的线程,使用了 AbstractQueuedSynchronizer 这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS 队列里面的头部唤醒下一个等待锁的线程。
- 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS 队列存在等待中的线程。
- 最后,关于锁的重入特性,在 AQS 里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。
28. 简述一下你对线程池的理解?
首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。
而线程池里面复用的是线程资源,它的核心设计目标,我认为有两个:
- 减少线程的频繁创建和销毁带来的性能开销,因为线程创建会涉及到 CPU 上下文切换、内存分配等工作。
- 线程池本身会有参数来控制线程创建的数量,这样就可以避免无休止的创建线程带来的资源利用率过高的问题,起到了资源保护的作用。
其次,我简单说一下线程池里面的线程复用技术。因为线程本身并不是一个受控的技术,也就是说线程的生命周期时由任务运行的状态决定的,无法人为控制。所以为了实现线程的复用,线程池里面用到了阻塞队列,简单来说就是线程池里面的工作线程处于一直运行状态,它会从阻塞队列中去获取待执行的任务,一旦队列空了,那这个工作线程就会被阻塞,直到下次有新的任务进来。
也就是说,工作线程是根据任务的情况实现阻塞和唤醒,从而达到线程复用的目的。最后,线程池里面的资源限制,是通过几个关键参数来控制的,分别是核心线程数、最大线程数。
核心线程数表示默认长期存在的工作线程,而最大线程数是根据任务的情况动态创建的线程,主要是提高阻塞队列中任务的处理效率。
29. 如何中断一个正在运行的线程?
首先,线程是系统级别的概念,在 Java 里面实现的线程,最终的执行和调度都是由操作系统来决定的,JVM 只是对操作系统层面的线程做了一层包装而已。
所以我们在Java 里面通过start 方法启动一个线程的时候,只是告诉操作系统这个线程可以被执行,但是最终交给 CPU 来执行是操作系统的调度算法来决定的。
因此,理论上来说,要在Java 层面去中断一个正在运行的线程,只能像类似于 Linux里面的kill 命令结束进程的方式一样,强制终止。
所以,Java Thread 里面提供了一个 stop 方法可以强行终止,但是这种方式是不安全的,因为有可能线程的任务还没有结束,导致出现运行结果不正确的问题。
要想安全的中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序通过这个钩子来触发线程的中断命令。因此,在Java Thread 里面提供了一个 interrupt()方法
,这个方法配合 isInterrupted()方法使用,就可以实现安全的中断机制。
这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了,不过是否要中断,取决于正在运行的线程,所以它能够保证线程运行结果的安全性。
30. 为什么引入偏向锁、轻量级锁,介绍下升级流程
- Synchronized 在jdk1.6 版本之前,是通过重量级锁的方式来实现线程之间锁的竞争。之所以称它为重量级锁,是因为它的底层底层依赖操作系统的 Mutex Lock 来实现互斥功能。Mutex 是系统方法,由于权限隔离的关系,应用程序调用系统方法时需要切换到内核态来执行。这里涉及到用户态向内核态的切换,这个切换会带来性能的损耗。
- 在jdk1.6 版本中,synchronized 增加了锁升级的机制,来平衡数据安全性和性能。简单来说,就是线程去访问 synchronized 同步代码块的时候,synchronized 根 据线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向锁和轻量级锁的机制。
- 偏向锁,就是直接把当前锁偏向于某个线程,简单来说就是通过 CAS 修改偏向锁标记,这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。
- 轻量级锁也可以称为自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自旋锁优点在于它避免避免了用户态到内核态的切换带来的性能开销。
3.Synchronized 引入了锁升级的机制之后,如果有线程去竞争锁:
- 首先,synchronized 会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。
- 如果竞争锁失败,说明当前锁已经偏向了其他线程。需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源,如果在轻量级锁状态下还是没有竞争到锁,
- 就只能升级到重量级锁,在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线程状态是Blocked。
处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。
总的来说, Synchronized 的锁升级的设计思想,在我看来本质上是一种性能和安全性的平衡,也就是如何在不加锁的情况下能够保证线程安全性。这种思想在编程领域比较常见,比如 Mysql 里面的MVCC 使用版本链的方式来解决多个并行事务的竞争问题。
31. 说一下你对 CompletableFuture 的理解
CompletableFuture 是JDK1.8 里面引入的一个基于事件驱动
的异步回调类
。
简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作。
而CompletableFuture 就可以实现这个功能。
举个简单的例子,比如在一个批量支付的业务逻辑里面,涉及到查询订单、支付、发送邮件通知这三个逻辑。
这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。而这种设计方式导致这个方法的执行性能比较慢。
所以,这里可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。然后基于CompletableFuture 的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。从而极大的提升这个这个业务场景的处理性能!
CompletableFuture 提供了 5 种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。
- 第一种,
thenCombine
(如图),把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。
- 第二种,
thenCompose
(如图),把两个任务组合在一起,这两个任务串行执行,也就是第一个任务执行完以后自动触发执行第二个任务
- 第三种,
thenAccept
(如图),第一个任务执行结束后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。
- 第四种,
thenApply
(如图),和thenAccept 一样,但是它有返回值
- 第五种,
thenRun
(如图),就是第一个任务执行完成后触发执行一个实现了 Runnable 接口的任务。
最后,我认为,CompletableFuture 弥补了原本Future 的不足,使得程序可以在非阻塞的状态下完成异步的回调机制。
32. 线程状态,BLOCKED 和 WAITING 有什么区别?
BLOCKED 和WAITING 都是属于线程的阻塞等待状态。
- BLOCKED 状态是指线程在等待监视器锁的时候的阻塞状态。
也就是在多个线程去竞争 Synchronized 同步锁的时候,没有竞争到锁资源的线程,会被阻塞等待,这个时候线程状态就是BLOCKED。
在线程的整个生命周期里面,只有 Synchronized 同步锁等待才会存在这个状态
- WAITING 状态,表示线程的等待状态
在这种状态下,线程需要等待某个线程的特定操作才会被唤醒。
我们可以使用Object.wait()、Object.join()、LockSupport.park()这些方法使得线程进入到 WAITING 状态,在这个状态下,必须要等待特定的方法来唤醒,比如Object.notify 方法可以唤醒Object.wait()方法阻塞的线程 LockSupport.unpark()可以唤醒LockSupport.park()方法阻塞的线程。
所以,在我看来,BLOCKED 和WAITING 两个状态最大的区别有两个:
- BLOCKED 是锁竞争失败后被被动触发的状态,WAITING 是人为的主动触发的状态
- BLCKED 的唤醒时自动触发的,而 WAITING 状态是必须要通过特定的方法来主动唤醒
33. Thread 和Runnable 的区别
Thread 和Runnable 接口的区别有 4 个。
- Thread 是一个类,Runnable 是接口,因为在 Java 语言里面的继承特性,接口可以支持多继承,而类只能单一继承。
- Runnable 表示一个线程的顶级接口,Thread 类其实也是实现了Runnable 接口;
- 站在面向对象的思想来说,Runnable 相当于一个任务,而 Thread 才是真正处理的线程,所以我们只需要用 Runnable 去定义一个具体的任务,然后交给 Thread去处理就可以了,这样达到了松耦合的设计目的。
- 接口表示一种规范或者标准,而实现类表示对这个规范或者标准的实现,所以站在线程的角度来说,Thread 才是真正意义上的线程实现。Runnable 表示线程要执行的任务,因此在线程池里面,提交一个任务传递的类型是 Runnable。
35. wait 和sleep 是否会触发锁的释放以及CPU 资源的释放?
- Object.wait()方法,会释放锁资源以及CPU 资源。
- Thread.sleep()方法,不会释放锁资源,但是会释放 CPU 资源。
首先,wait()方法是让一个线程进入到阻塞状态,而这个方法必须要写在一个 Synchronized 同步代码块里面。
因为wait/notify 是基于共享内存来实现线程通信的工具,这个通信涉及到条件的竞争,所以在调用这两个方法之前必须要竞争锁资源。当线程调用wait 方法的时候,表示当前线程的工作处理完了,意味着让其他竞争同一个共享资源的线程有机会去执行。但前提是其他线程需要竞争到锁资源,所以 wait 方法必须要释放锁,否则就会导致死锁的问题。
然后,Thread.sleep()方法,只是让一个线程单纯进入睡眠状态,这个方法并没有强制要求加synchronized 同步锁。
而且从它的功能和语义来说,也没有这个必要。当然,如果是在一个 Synchronized 同步代码块里面调用这个Thread.sleep,也并不会触发锁的释放。
最后,凡是让线程进入阻塞状态的方法,操作系统都会重新调度实现 CPU 时间片切换,这样设计的目的是提升 CPU 的利用率。
36. 讲下线程池的线程回收
首先,线程池里面分为核心线程和非核心线程。核心线程是常驻在线程池里面的工作线程
它有两种方式初始化:
- 向线程池里面添加任务的时候,被动初始化
- 主动调用
prestartAllCoreThreads
方法
当线程池里面的队列满了的情况下,为了增加线程池的任务处理能力。线程池会增加非核心线程。
核心线程和非核心线程的数量,是在构造线程池的时候设置的,也可以动态进行更改。
由于非核心线程是为了解决任务过多的时候临时增加的,所以当任务处理完成后,工作线程处于空闲状态的时候,就需要回收。
因为所有工作线程都是从阻塞队列中去获取要执行的任务,所以只要在一定时间内,阻塞队列没有任何可以处理的任务,那这个线程就可以结束了。
这个功能是通过阻塞队列里面的poll
方法来完成的。这个方法提供了超时时间和超时时间单位这两个参数
当超过指定时间没有获取到任务的时候,poll 方法返回null,从而终止当前线程完成线程回收。
默认情况下,线程池只会回收非核心线程,如果希望核心线程也要回收,可以设置allowCoreThreadTimeOut
这个属性为true,一般情况下我们不会去回收核心线程。因为线程池本身就是实现线程的复用,而且这些核心线程在没有任务要处理的时候是处于阻塞状态并没有占用CPU 资源。
37. 如果一个线程两次调用 start(),会出现什么问题?
在Java 里面,一个线程只能调用一次 start()方法,第二次调用会抛出 IllegalThreadStateException
。
一个线程本身是具备一个生命周期的。
在Java 里面,线程的生命周期包括 6 种状态。
- NEW,线程被创建还没有调用start 启动
- RUNNABLE,在这个状态下的线程有可能是正在运行,也可能是在就绪队列里面等待操作系统进行调度分配 CPU 资源。
- BLOCKED,线程处于锁等待状态
- WAITING,表示线程处于条件等待状态,当触发条件后唤醒,比如wait/notify。
- TIMED_WAIT,和WAITING 状态相同,只是它多了一个超时条件触发
- TERMINATED,表示线程执行结束
当我们第一次调用start()方法的时候,线程的状态可能处于终止或者非 NEW 状态下的其他状态。
再调用一次start(),相当于让这个正在运行的线程重新运行,不管从线程的安全性角度,还是从线程本身的执行逻辑,都是不合理的。因此为了避免这个问题,在线程运行的时候会先判断当前线程的运行状态。
38. Java 官方提供了哪几种线程池,分别有什么特点?
JDK 中幕刃提供了 5 中不同线程池的创建方式:
newCachedThreadPool
是一种可以缓存的线程池,它可以用来处理大量短期的突发流量。
它的特点有三个,最大线程数是Integer.MaxValue,线程存活时间是 60 秒,阻塞队列用的是 SynchronousQueue,这是一种不存储任何元素的阻塞队列,也就是每提交一个任务给到线程池,都会分配一个工作线程来处理,由于最大线程数没有限制。所以它可以处理大量的任务,另外每个工作线程又可以存活 60s,使得这些工作线程可以缓存起来
应对更多任务的处理。newFixedThreadPool
是一种固定线程数量的线程池。
它的特点是核心线程和最大线程数量都是一个固定的值,如果任务比较多工作线程处理不过来,就会加入到阻塞队列里面等待。newSingleThreadExecutor
只有一个工作线程的线程池。
并且线程数量无法动态更改,因此可以保证所有的任务都按照 FIFO 的方式顺序执行。newScheduledThreadPool
,具有延迟执行功能的线程池可以用它来实现定时调度。newWorkStealingPool
,Java8 里面新加入的一个线程池,它内部会构建一个ForkJoinPool,利用工作窃取的算法并行处理请求。
这些线程都是通过工具类Executors 来构建的,线程池的最终实现类是 ThreadPoolExecutor;
39. 请你说一下你对 Happens-Before 的理解。
首先,Happens-Before
是一种可见性模型,也就是说,在多线程环境下。
原本因为指令重排序的存在会导致数据的可见性问题,也就是 A 线程修改某个共享变量对B 线程不可见。
因此,JMM 通过Happens-Before 关系向开发人员提供跨越线程的内存可见性保证。
如果一个操作的执行结果对另外一个操作可见,那么这两个操作之间必然存在 Happens-Before 管理。
其次,Happens-Before 关系只是描述结果的可见性,并不表示指令执行的先后顺序,也就是说只要不对结果产生影响,仍然允许指令的重排序。
最后,在JMM 中存在很多的Happens-Before 规则。
- 程序顺序规则,一个线程中的每个操作,happens-before 这个线程中的任意后续操作,可以简单认为是as-if-serial也就是不管怎么重排序,单线程的程序的执行结果不能改变;
- 传递性规则(如图),也就是 A Happens-Before B,B Happens-Before C。就可以推导出A Happens-Before C。
- volatile 变量规则,对一个 volatile 修饰的变量的写一定 happens-before 于任意后续对这个volatile 变量的读操作
- 监视器锁规则(如图),一个线程对于一个锁的释放锁操作,一定happens-before 与后续线程对这个锁的加锁操作,在这个场景中,如果线程A 获得了锁并且把x 修改成了 12,那么后续的线程获得锁之后得到的x 的值一定是 12.
- 线程启动规则(如图),如果线程 A 执行操作ThreadB.start(),那么线程A 的 ThreadB.start()之前的操作happens-before 线程B 中的任意操作。在这样一个场景中,t1 线程启动之前对于x=10 的赋值操作,t1 线程启动以后读取x的值一定是 10.
- join 规则(如图),如果线程 A 执行操作ThreadB.join()并成功返回,那么线程B 中的任意操作happens-before 于线程A 从ThreadB.join()操作成功的返回。
40. 线程池是如何实现线程复用的?
线程池里面采用了生产者消费者的模式,来实现线程复用
。
生产者消费者模型,其实就是通过一个中间容器来解耦生产者和消费者的任务处理过程。
- 生产者不断生产任务保存到容器,消费者不断从容器中消费任务。在线程池里面,因为需要保证工作线程的重复使用,并且这些线程应该是有任务的时候执行,没任务的时候等待并释放CPU 资源。因此(如图),它使用了阻塞队列来实现这样一个需求。
提交任务到线程池里面的线程称为生产者线程
,它不断往线程池里面传递任务。这些任务会保存到线程池的阻塞队列里面。然后线程池里面的工作线程不断从阻塞队列获取任务去执行。
基于阻塞队列的特性,使得阻塞队列中如果没有任务的时候,这些工作线程就会阻塞等待。
直到又有新的任务进来,这些工作线程再次被唤醒。从而达到线程复用的目的;
41. 可以说下阻塞队列被异步消费怎么保持顺序吗?
- 首先,阻塞队列本身是符合 FIFO 特性的队列,也就是存储进去的元素符合先进先出的规则。
- 其次,在阻塞队列里面,使用了condition 条件等待来维护了两个等待队列(如图),一个是队列为空的时候存储被阻塞的消费者,另一个是队列满了的时候存储被阻塞的生产者并且存储在等待队列里面的线程,都符合 FIFO 的特性。
最后,对于阻塞队列的消费过程,有两种情况。 - 第一种,就是阻塞队列里面已经包含了很多任务,这个时候启动多个消费者去消费的时候,它的有序性保证是通过`·加锁·来实现的,也就是每个消费者线程去阻塞队列获取任务的时候必须要先获得排他锁。
- 第二种,如果有多个消费者线程因为阻塞队列中没有任务而阻塞,这个时候这些线程是按照FIFO 的顺序存储到
condition 条件等待队列中的。 当阻塞队列中开始有任务要处理的时候,这些被阻塞的消费者线程会严格按照FIFO 的顺序来唤醒,从而保证了消费的顺序型。
42. 当任务数超过线程池的核心线程数时,如何让它不进入队列,而是直接启用最大线程数
当我们提交一个任务到线程池的时候,它的工作原理分为四步。
- 第一步,预热核心线程
- 第二步,把任务添加到阻塞队列
- 第三步,如果添加到阻塞队列失败,则创建非核心线程增加处理效率
- 第四步,如果非核心线程数达到了阈值,就触发拒绝策略
所以,如果希望这个任务不进入队列,那么只需要去影响第二步的执行逻辑就行了。 Java 中线程池提供的构造方法里面,有一个参数可以修改阻塞队列的类型。其中,就有一个阻塞队列叫 SynchronousQueue
(如图), 这个队列不能存储任何元素。它的特性是,每生产一个任务,就必须要指派一个消费者来处理,否则就会阻塞生产者。
基于这个特性,只要把线程池的阻塞队列替换成 SynchronousQueue。
就能够避免任务进入到阻塞队列,而是直接启动最大线程数去处理这个任务。
43. SimpleDateFormat 是线程安全的吗? 为什么?
SimpleDateFormat 不是线程安全的
, SimpleDateFormat 类内部有一个Calendar 对象
引用, 它用来储存和这个SimpleDateFormat 相关的日期信息。
- 当我们把SimpleDateFormat 作为多个线程的共享资源来使用的时候。意味着多个线程会共享 SimpleDateFormat 里面的Calendar 引用,多个线程对于同一个Calendar 的操作,会出现数据脏读现象导致一些不可预料的错误。
实际应用中,我认为有 4 种方法可以解决这个问题。
- 第一种,把SimpleDateFormat 定义成局部变量,每个线程调用的时候都创建一个新的实例。
- 第二种,使用ThreadLocal 工具,把SimpleDateFormat 变成线程私有的
- 第三种,加同步锁,在同一时刻只允许一个线程操作SimpleDateFormat
- 第四种,在Java8 里面引入了一些线程安全的日期API,比如 LocalDateTimer、 DateTimeFormatter 等。
44. 并行和并发有什么区别?
并行和并发是Java 并发编程里面的概念。
并行,是指在多核CPU 架构下,同一时刻同时可以执行多个线程的能力
。- 在单核CPU 架构中,同一时刻只能运行一个线程。
- 在 4 核 4 线程的CPU 架构中,同一时刻可以运行 4 个线程,那这 4 个线程就是并行执行的。
并发,是指在同一时刻 CPU 能够处理的任务数量,也可以理解成CPU 的并发能力
。- 在单核CPU 架构中,操作系统通过CPU 时间片机制提升 CPU 的并发能力
- 在多核CPU 架构中,基于任务的并行执行能力以及 CPU 时间片切换的能力来提升CPU的并发能力。
所以,总的来说,并发是一个宏观概念,它指的是 CPU 能够承载的压力大小,并行是一个微观概念,它描述 CPU 同时执行多个任务的能力。
45. ThreadLocal 会出现内存泄漏吗?
ThreadLocal 是一个用来解决线程安全性问题的工具。它相当于让每个线程都开辟一块内存空间,用来存储共享变量的副本。然后每个线程只需要访问和操作自己的共享变量副本即可,从而避免多线程竞争同一个共享资源。
它的工作原理很简单(如图)
每个线程里面有一个成员变量 ThreadLocalMap。当线程访问用ThreadLocal 修饰的共享数据的时候
这个线程就会在自己成员变量 ThreadLocalMap 里面保存一份数据副本。key 指向ThreadLocal 这个引用,并且是弱引用关系,而 value 保存的是共享数据的副本。因为每个线程都持有一个副本,所以就解决了线程安全性问题。
Thread 中的成员变量ThreadLocalMap,它里面的可以 key 指向ThreadLocal 这个成员变量,并且它是一个弱引用
所谓弱引用,就是说成员变量 ThreadLocal 允许在这种引用关系存在的情况下,被 GC回收。
一旦被回收,key 的引用就变成了null,就会导致这个内存永远无法被访问,造成内存泄漏。
规避内存泄漏的方法有两个:
- 通过扩大成员变量ThreadLoca 的作用域,避免被 GC 回收
- 每次使用完ThreadLocal 以后,调用remove 方法移除对应的数据
第一种方法虽然不会造成key 为null 的现象,但是如果后续线程不再继续访问这个key。也会导致这个内存一直占用不释放,最后造成内存溢出的问题。
所以我认为最好是在使用完以后调用 remove 方法移除
46. 进程和线程的区别?
进程
就是应用程序在内存中分配的空间,也就是正在运行的程序,各个进程之间互不干扰。同时进程保存着程序每一个时刻运行的状态。
此时,CPU采用时间片轮转的方式运行进程:CPU为每个进程分配一个时间段,称作它的时间片。如果在时间片结束时进程还在运行,则暂停这个进程的运行,并且CPU分配给另一个进程(这个过程叫做上下文切换)。如果进程在时间片结束前阻塞或结束,则CPU立即进行切换,不用等待时间片用完。
使用进程+CPU时间片轮转方式的操作系统,在宏观上看起来同一时间段执行多个任务,换句话说,进程让操作系统的并发成为了可能。虽然并发从宏观上看有多个任务在执行,但在事实上,对于单核CPU来说,任意具体时刻都只有一个任务在占用CPU资源。
虽然进程的出现,使得操作系统的性能大大提升,但是随着时间的推移,人们并不满足一个进程在一段时间只能做一件事情,如果一个进程有多个子任务时,只能逐个地执行这些子任务,很影响效率。
那么能不能让这些子任务同时执行呢?于是人们又提出了线程
的概念,让一个线程执行一个子任务,这样一个进程就包含了多个线程,每个线程负责一个单独的子任务。
总之,进程和线程的提出极大地提高了操作系统的性能。进程让操作系统的并发性成为了可能,而线程让进程的内部并发成为了可能。