本文列举了十几点关于多线程的常见问题,并做了简要回答,类似于快问快答,可用于知识点总览或面试突击。每个问题涉及的知识点还可以继续深入探索,可以另起一篇文章。
一、Java创建线程有几种方式?
Java创建多线程主要有三种
1,继承Thread类创建线程类
2,通过Runnable接口创建线程类
3,通过Callable和Future创建线程
Callable和Future创建线程代码案例:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class CallableThreadTest implements Callable<Integer> {
public static void main(String[] args) {
CallableThreadTest ctt = new CallableThreadTest();
FutureTask<Integer> futureTask = new FutureTask<>(ctt);
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " 的循环变量i的值" + i);
if (i == 20) {
new Thread(futureTask, "有返回值的线程").start();
}
}
try {
System.out.println("子线程的返回值:" + futureTask.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
@Override
public Integer call() throws Exception {
int i = 0;
for (; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
return i;
}
}
创建线程的三种方式的对比
1,继承Thread类的实现类,可以直接使用this来获取当前线程,而实现Runnable和Callable接口的需要使用Thread.currentThread()方法。
2,Callable规定重写的方法是call(),Runnable规定重写的方法是run()。
3,Callable的任务执行后有返回值,Runnable的任务不能有返回值。
4,call方法可以抛出异常,run方法不可以。
5,运行Callab任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Fut对象可以了解任务执行情况,可以取消任务的执行,还可以获取执行接口。
二、线程有哪几种状态(生命周期)
java Thread的运行周期中,有六种状态,在java.lang.Thread.State中有详细定义和说明:
NEW:状态是指线程刚创建,尚未启动。
RUNNABLE:状态是线程正在正常运行中,当然可能会有某种耗时计算I/O等待的操作/cpu时间片切换等,这个状态下发生的等待一般是其他系统资源,而不是锁,Sleep等。
BLOCKED:状态是在多个线程有同步操作,比如正在等待另一个线程的synchronized块的执行释放,或者可重入的synchronized块里别人调用wait()方法,也就是这里是线程在等待进入临界区。
WAITING:这个状态下是指线程拥有了某个锁之后,调用了他的wait方法,等待其他线程/锁拥有者调用 notify/notifyAll 以便该线程可以继续下一步操作。这里要区分BLOCKED和WAITING的区别,一个是在临界点外面等待进入,一个是在临界点里面wait等待别人notify,线程调用了join方法join了另外的线程的时候,也会进入WAITING状态,等待被他join的线程执行结束
TIMED_WAITING:这个状态就是有时间限制的WAITING,一般出现在调用wait(long),join(long)等情况下,另外一个线程sleep后,也会进入TIMED_WAITING状态。
TERMINATED:这个状态下表示,该线程的run方法已经执行完毕了,基本上等于死亡状态(当时如果线程被持久持有,可能不会被回收).
三、synchronized和Lock锁的区别
1,首先synchronized是java内置关键字,在jvm层面,Lock是个接口类。
2,synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁。
3,synchronized会自动释放锁(a,线程执行完同步代码会释放锁;b,线程执行过程中发现异常会释放锁),Lock是显示锁,需要在finally中手动释放锁(unlock()方法释放锁,或者设定超时时间),否则容易造成线程死锁。
4,synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可判断、公平/非公平。
5,synchronized锁适合代码少量的同步问题,Lock锁适合大量代码的同步问题。
四 、sleep和wait区别
1,sleep方法是Thread类的静态方法,wait方法是Object超类的成员方法
2,slepp方法使当前线程暂停执行指定的时间,让出cpu给其他线程,但是它的监控状态依然保持着,当指定的 时间到了又会自动恢复运行状态。在调用sleep方法后,线程不会释放对象锁。
而当调用wait方法时,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象 调用notify()或者notifyAll()方法后本线程才进入对象锁定池处于准备状态。
3,sleep方法可以在任何地方使用;wait方法只能在同步方法和同步代码块中使用。
五、什么是死锁?
1,什么是死锁
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当处于这种僵局时,若无外力作用,他们将无法继续向前推进。
2,死锁的原因
1)因竞争资源而发生死锁现象:系统中供多个进程共享的资源的数目不足以满足全部进程的需要时,就会引发对资源的竞争而发生死锁现象。
2)进程推进顺序不当发生死锁。
3,产生死锁的四个必要条件
1)互斥条件:在一段时间内,一个资源只能被一个进程占用
2)请求和保持条件:当进程因请求其他资源而阻塞时,对已获得的资源保持不放。
3)不剥夺条件:进程已获得的资源在使用结束之前,不能被剥夺,只能等使用结束时自己释放资源。
4)环路等待条件:在发生死锁时,必然存在一个进程--资源的环形链。
4,处理死锁的基本方法
1)预防死锁:通过设置一些限制条件,去破坏产生死锁的必要条件。这样处理会对系统性能有一定的影响。
2)避免死锁:在资源分配过程中,通过一些算法避免系统进入不安全状态,从而避免产生死锁。比较有名的算法是银行家算法。
3)检测死锁:允许死锁的产生,但是通过系统的检测,采取一些措施,将死锁清除掉。
4)解除死锁:配合检查死锁一起使用。
六,Volatile关键字详解
相关概念,Java内存模型中的可见性,原子性和有序性
1,volatile原理
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。
当把变量申明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取vola类型的变量时,总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
2,volatile具备的两种特性
1)保证此变量对所有的线程的可见性。
2)禁止指令重排序优化。
3,volatile性能
volatile对读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
4,volatile能保证原子性吗?
这里有一个误区,volatile关键字能保证可见性没有错,但是没能保证原子性。可见性只能保证每次读取的是最新值,但是volatile没办法保证对变量的操作的原子性。
5,volatile能保证有序性吗?
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
6,volatile的原理和实现机制
下面这句话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行失效。
7,使用volatile关键字的场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键在在某些情况下性能要由于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖当前值
2)该变量没有包含在具有其他变量的不变式中也就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正常执行。
下面列举在Java中使用volatile的2个场景
1,状态标记量
volatile boolean inited = false;
//线程1:
context = loadContext();
inited = true;
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
2,double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
七、什么是CAS机制
CAS就是Compare and Swap的单词缩写,翻译过来就是比较并替换。
CAS机制使用了三个基本的操作数:内存地址V,旧的预期值A,要要修改的新值B。
要修改一个变量的值时,只有当变量的预期值A与内存地址V的实际值相等时,才会将内存地址的实际值为修改为新值B。
从思想上来看,synchronized属于悲观锁,悲观的认为程序中的并发情况很严重,所有严防死守;
而CAS属于乐观锁,乐观的认为实际的并发情况并没有那么严重,所以让程序不断的去重试更新。
在java提供的Atomic系列类,以及Lock系列类的底层实现,甚至在java1.6以上版本的synchronized转变为重量级锁之前,也都会采用CAS机制。
CAS缺点:
1)CPU开销过大
在并发量很高的情况下,如果许多线程反复尝试去更新一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
2)不能保证代码块的原子性
CAS机制只是保证了一个变量的原子性操作,不能保证整个代码块的原子性,比如要对3个变量共同进行原子性的更新,就不得不使用synchronized了。
3)ABA问题
这是CAS机制最大的问题所在。
一个变量从A->B->A的变化,虽然两个值A都是相同的,但是条件已不同,会让程序产生不正确的结果。
如上图,举例:提款机案例,原本内存地址V值是100存款,用户提取50元,B更新为50,然后同时提交了两个线程1和2,理想情况下希望是一个线程成功,一个线程失败。此时用户的朋友给他转账50,更新值为100,也就是线程3,最终结果期望的是内存地址V的值是100。三个线程是并发的,刚好执行顺序是线程1 -> 线程3 -> 线程2,最后的结果却是内存地址V是50。这样的结果肯定是不正确的。
ABA问题的解决方式:真正要做到严谨的CAS机制,我们在compare阶段不仅要对期望值A和内存地址V的实际值进行比较,还要比较变量的版本号是否一致。
八、怎么理解悲观锁和乐观锁
悲观锁
总是假设最坏的情况,每次去拿数据时都认为别人会修改,所以每次拿到数据时都会上锁,这样别人想要拿到这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程)。传统的关系型数据库很多就用到了这种锁机制,比如表锁,行锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是用到了悲观锁的思想。
乐观锁
总是假设最好的情况,每次去拿数据时都认为别人不会修改,所以不会上锁,但是在更新时会去判断在此期间别人有没有更新过此数据,可用使用版本号机制和CAS算法实现。像数据库提供的类似于wite_condition机制,和Java中java.util.concurrent.Atomic包下面的原子变量操作类也是使用乐观锁的一种实现方式CAS算法实现的。
两种锁的使用场景
从上面对两种锁的介绍,我们可以知道两种锁各有优缺点,不可认为谁比谁好,像乐观锁适用于读多写少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了整个系统的吞吐量。如果是写多读少的情况下,一般会经常发生冲突,这就导致上层应用不断的进行retry,这样反倒降低了性能,所以一般写多读少的情况下使用悲观锁更合适。
九、什么是偏向锁、轻量级锁、重量级锁
这几个锁的概念可自行百度。这里简单介绍对比下区别。
偏向锁:
优点:加锁和解锁不需要额外的消耗,和执行非同步方法仅存在纳秒级差距。
缺点:如果线程存在锁的竞争,会带来额外的锁撤销的消耗。
适用场景:适用于只有一个线程访问同步代码块的场景
轻量级锁:比如用了CAS机制的锁
优点:竞争的线程不会阻塞,提高了程序的响应速度。
缺点:如果始终得不到锁竞争的线程,适用自旋会消耗CPU。
适用场景:追求响应时间,锁占用时间很短
重量级锁:比如synchronized
优点:线程不使用自旋,不会消耗CPU
缺点:线程阻塞,响应时间缓慢
适用场景:追求吞吐量,锁占用时间较长。
十、说一说ThreadLocal
1,什么是ThreadLocal
ThreadLocal给线程提供局部变量。这些变量与普通变量不同之处在于,每个访问(通过他的get,set方法)这种变量的线程都有他自己的、独立初始化问的变量副本。
ThreadLocal 的实例通常是希望它的状态关联到一个线程的类的私有静态字段(比如User ID或Transaction ID等等)。
2,ThreadLocal用在哪些地方
ThreadLocal是用在多线程上的,若只有一个线程,则不需要ThreadLocal。
总结归纳下来,有2类用途:
1)保存线程上下文信息,在任意地方就可以获取;
2)线程安全的,避免某些情况下需要考虑线程安全必须同步带来的性能损失。
保存线程上下文信息,在任意地方就可以获取。
由于ThreadLocal的特性,同一线程在某个地方设置后,随后在其他任意地方都可以获取到,从而可以用来保存线程上下文信息。
比如常用来怎么把每一个请求的一串后续关联起来,就可以用ThreadLocal进行set,在后续任意需要记录日志的方法里面进行get获取到请求id,从而将整个请求串起来。
还有spring的事务管理,用ThreadLocal存储Connection,从而让各个DAO获取同一个Connection,可以进行事务回滚、提交等操作。
线程安全的,避免某些情况下需要考虑线程安全必须同步带来的性能损失。
ThreadLocal为解决多线程程序的并发问题提供了一种新的解决思路。但是ThreadLocal也有局限性,我们来看看阿里规范。
每个线程往ThreadLocal中读写数据是线程隔离,相互之间互不影响,所以ThreadLocal无法解决共享对象的更新问题。
3,ThreadLocal有哪一些细节
Thread类有成员属性threadLocals(类型是ThreadLocal. ThreadLocalMap),也就是说每个线程都有一个ThreadLocalMap,每个线程往这里面读写数据是相互隔离的,互不影响。
看源码我们得知,Entry的key指向ThreadLocal是弱引用(WeakReference)。
java引用类型分:强引用,软引用,弱引用,虚引用。而这里的弱引用是指,弱引用用来描述非必需对象的,如果jvm进行垃圾回收,无论内存是否充足,该对象仅仅被弱引用关联,那么该对象就会被回收。
当仅仅只有ThreadLocalMap中的Entry的key指向ThreadLocal时,ThreadLocal是会被回收的!!
ThreadLocal被垃圾回收后,ThreadLocalMap中的Entry的key会变成null,而Entry是强引用,里面存储的是Object,没有办法进行回收,因此ThreadLocalMap做了一些额外的回收工作。
4,ThreadLocal最佳实践
由于ThreadLocal的生命周期很长,如果我们往ThreadLocal里面set了很大的数据,虽然set,get方法在特定情况下调用会进行额外的清理,若ThreadLocal被垃圾回收了,在ThreadLocalMap中的Entry的key变为了null,后续也没有操作set,get等方法了。
存在内存泄露问题,所以最佳实现,我们应该在不使用的时候,主动进行remove方法进行清理。
这里把ThreadLocal定义为static还有一个好处就是,由于ThreadLocal变为强引用,在ThreadLoalMap的Entry的键值就永远存在,那么进行remove方法的时候就可以正确定位到并且删除成功。
5,应用场景
当某些数据是以线程为作用域且不同线程有不同数据副本时,考虑用ThreadLocal;
无状态,副本独立后不影响业务逻辑的高并发场景;
若业务逻辑强依赖副本变量时,则不适合用ThreadLocal;
synchronized是通过线程等待,以牺牲时间来解决并发冲突的;
ThreadLocal是每个线程存储一份数据,以牺牲空间来解决并发冲突的。
十一、谈谈ConcurrentHashMap
1,ConcurrentHashMap使用什么技术来保证线程安全?
jdk1.7: Segment + HashEntry来进行实现的;
jdk1.8:方法了Segment臃肿的涉及,采用Node+CAS+Synchronized来保证线程安全/
2,ConcurrentHashMap的get方法是否要加锁,为什么?
不需要,get方法采用了unsafe方法,来保证线程安全。
3,ConcurrentHashMap迭代器是强一致性还是弱一致性?HashMap呢?
ConcurrentHashMap是弱一致性,HashMap是强一致性。
前者可以支持在迭代过程中,向map添加新元素,而HashMap则抛出了ConcurrentModificationException,
因为HashMap包含一个修改计数器,当你调用它的next()方法来获取下一个元素时,迭代器将会用到这个计数器。
4,ConcurrentHashMap1.7和1.8的区别?
jdk1.7锁的粒度时基于Segment的,包含多个HashEntry,jdk1.8的实现降低锁的粒度,粒度是Node。
数据结构,jdk1.7是Segment+HashEntry,jdk1.8是数组+链表+红黑树+CAS+synchronized
5,在ConcurrentHashMap1.8中什么时候会用链表、什么时候会用红黑树?
初始化都是链表,链表长度超过默认8位,该结点会转换为红黑树。
十二、CountDownLatch详解
1,什么是CountDownLatch?
CountDownLatch是在java1.6被引入的,跟他一起被引入的并发工具类还有CyclicBarrier、Semaphore、
ConcurrentHashMap和BlockingQueue,他们都存在于java.util.concurrent包下。CountDownLatch这个类
能够使用一个或多个线程等待其他线程完成各自的工作后再执行。
例如,应用程序的主线程希望再负责启动框架服务的线程已经启动所有的框架的服务之后再执行。
2,CountDownLatch的实现原理
CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,
计数器的值就会减1。当计数器的值到达0时,它表示所有的线程已经完成了任务,然后在闭锁上等待的线程
就可以恢复执行任务。 CountDownLatch.java类中定义的构造函数:
构造器中的计数值count实际上就是闭锁需要等待的线程数。这个值只能被设置一次,而且CountDownLatch没有供任何机制去重新设置这个计数值。
与CountDownLatch的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用
CountDownLatch.await()方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。
其他N个线程必须引用闭锁对象,因为他们需要通知CountDownLatch对象,他们已经完成了各自的任务。
这种通知机制是通过CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的
count值就减1。所以当N个线程都调用了这个方法,count值等于0,然后主线程就能通过await()方法,恢复执行自己的任务。
3,CountDownLatch类中主要的方法
await():当前线程等待直到计数器为0;
countDown():计数器减1,这个是其他线程调用此方法。代表其他线程任务执行完成。
4,CountDownLatch的应用场景
尝试罗列几种应用场景,当然还有其他。
1)实现最大的并发性:
有时我们想同时启动多个线程,实现最大程度的并行性。例如,我们想测试一个单例类,如果创建一个初始计数为1的
CountDownLatch,并让所有线程都在这个锁上等待,那么我们可以很轻松的完成测试。因为我们只需要调用一次countDown()方法就可以让所有等待的线程同时恢复执行。
2)开始执行前等待N个线程完成各自任务:例如应用程序启动类要确保在处理用户请求前,所有N个外部系统已经启动和运行了。
3)死锁检测:一个非常方便的使用场景是,你可以使用N个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试尝试死锁。
5,CyclicBarrier和CountDownLatch的区别
1)CyclicBarrier的某个线程运行到某个点后停止运行,直到所有线程都达到一个点,所有线程才会重新运行;
CountDownLatch线程运行到某个点后,计数值-1,该线程继续运行,直到计数值为0,则停止运行;
2)CyclicBarrier只能唤醒一个任务;CountDownLatch可以唤醒多个任务;
3)CyclicBarrier可以重用,CountDownLatch不可重用,当计数值为0时,CountDownLatch就不可再用了。