java面试八股文之------Java并发夺命23问
- 👨🎓1.java中线程的真正实现方式
- 👨🎓2.java中线程的真正状态
- 👨🎓3.如何正确停止线程
- 👨🎓4.java中sleep和wait的区别
- 👨🎓5.并发编程的三大特性
- 👨🎓6.什么是CAS,有什么优缺点
- 👨🎓7.Contended注解有什么用
- 👨🎓8.java中四种引用类型有哪些
- 👨🎓9.ThreadLocal的内存泄露问题只有value吗?
- 👨🎓10.java中的锁分类
- 👨🎓11.Synchronized在1.6的优化
- 👨🎓12.Synchronized实现的原理
- 👨🎓13.什么是AQS
- 👨🎓14.ReentrantLock与Synchrozed的异同
- 👨🎓15.ReentrantReadWriteLock的原理
- 👨🎓16.JDK提供了哪些线程池
- 👨🎓17.线程池的核心参数有什么
- 👨🎓18.线程池的状态
- 👨🎓19.线程池的执行流程
- 👨🎓20.线程池添加工作线程的流程
- 👨🎓21.线程池为何要构建空任务的非核心线程
- 👨🎓22.线程池使用完为何必须shutdown
- 👨🎓23.线程池的核心参数到底如何设置
👨🎓1.java中线程的真正实现方式
这个问题的答案可以根据工作年限的不同来回答了,若是工作五年以下面试初中级开发时,可以回答三种或者四种都是可以的,哪三种呢?就是下面三种了,若是面试高开最好回答是一种,我们需要从原理上来解释下为什么是一种
- 1.继承Thread类,重写run方法
public class Test1 extends Thread{ @Override public void run() { while(true){ System.out.println("线程1"); } } public static void main(String[] args) { new Test1().start(); } }
- 2.实现Runnable接口,重写run方法
public class TestThread { public static void main(String[] args) { new Thread(() -> { while(true) System.out.println("Runnable多线程1"); }).start(); new Thread(() -> { while(true) System.out.println("Runnable多线程2"); }).start(); } }
- 3.实现Callable接口,重写call方法,利用FutureTask接收返回值
public class TestThread { public static void main(String[] args) throws Exception{ FutureTask<String> futureTask = new FutureTask<>(()->{ int i =0 ; while(i<100) System.out.println("Callable线程1在执行:"+i++); return "线程1执行完了"; }); FutureTask<String> futureTask2 = new FutureTask<>(()->{ int i =0 ; while(i<100) System.out.println("Callable线程2在执行:"+i++); return "线程2执行完了"; }); new Thread(futureTask).start(); new Thread(futureTask2).start(); System.out.println(futureTask.get()); System.out.println(futureTask2.get()); } }
三种实现都比较简单(算线程池的话是四种)。若是初中级面试,这么回答是没有问题的,若是面试高开,这么回答就不是很好了。那该怎么回答呢?
java中本质上线程的创建技术只有一种,就是利用Thread+Runnable接口来实现多线程,其他所有方式都是基于Thread+Runnable接口来改造而来,所以本质上就只有一种。为什么这么说呢,对于使用Runnable的实现方式应该没有意义,那我们就来聊一聊Thread和Callable吧,我们通过继承Thread时,需要重写run方法,实际上这个run方法就是Runnable的,进到Thread的源码就可以看到他也实现了Runnable。而对于Callable怎么说呢,通过Callable来实现多线程时,我们必须使用FutureTask类对Callable的对象进行包装,然后将FutureTask传递给Thread,这样才能够启动多线程。我们看下FutureTask的run方法就会发现,他其实调取的就是Callable的call方法,然后将返回值存取了,而FutureTask之所以有run方法,就是因为他的父类继承了接口Runnable。所以实现了Callable本质上也还是使用Runnable实现的类。其实还有线程池,线程池不过使用Runnable还是Callable其实都是一样也都是利用的Runnable。这里不做源码展示了,感性的小伙伴可以看这里:Java创建线程的方式只有一种:Thread+Runnable,笔者在这里详细分析了所有多线程的实现方式是怎么利用Thread+Runnable进行变化的。
同样的Callable同样也实现了Runnable接口:
👨🎓2.java中线程的真正状态
线程状态我们常说的有这几种:新建(new)、就绪(runnable)、运行(running)、阻塞(block)、死亡(dead)。一般理解这些状态时我们是下面这样的:
其实这些线程状态并不是java定义出的状态,而是我们根据线程的运行过程自己定义的线程状态。其实java也为线程定义了自己的状态值。在Thread中有一个state枚举类,就是定义了线程的状态共有六种,如下:
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called <tt>Object.wait()</tt>
* on an object is waiting for another thread to call
* <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
* that object. A thread that has called <tt>Thread.join()</tt>
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
这才是java给线程定义的官方状态,是6种,下面列些各个状态的区别:
- NEW
这是新生主状态也就是利用start方法创建完线程就是NEW状态 - RUNNABLE
线程在竞争CPU资源期间是属于可运行状态,同时线程运行时java也将其归结到了这里 - WAITING
当执行了wait方法以后,线程释放锁就会进入这个状态,这个状态下的线程必须被手动唤起,不能自动唤起(正常情况BLOCKED会自动唤起)。 - BLOCKED
锁竞争失败后线程进入阻塞状态,该状态下会自动唤起,无需手动干预 - TIMED_WAITING
执行sleep方法会进入这个状态,该状态下不会释放锁资源,会在休眠状态结束后直接进入RUNNABLE状态 - TERMINATED
当run方法执行结束时,线程就会进入该状态了也就是死亡了
👨🎓3.如何正确停止线程
java本身提供了停止线程的方式:可以使用stop方法,不过stop方法已经加上了Dreprected注解,也就是不太推荐使用的方法。那我们该如何停止线程呢?常用的方法有三种:
-
1.通过Volatile来修饰boolean来实现线程停止
如下所示,当flag变化时我们可以让线程停止。public class TestThread { volatile static Boolean flag = true; public static void main(String[] args) throws Exception{ FutureTask<String> futureTask = new FutureTask<>(()->{ int i =0 ; while(flag) System.out.println("Callable线程1在执行:"+i++); return "线程1执行完了"; }); new Thread(futureTask).start(); System.out.println(futureTask.get()); } }
-
2.使用Interrupt方法+阻断标志来实现退出线程
每个线程默认都有一个阻断标志默认是false,interrupt方法就是改变阻断标志的方法,执行interrupt后线程的阻断标志就会变化为true,我们可以利用阻断标志的变化来停止线程,如下所示在线程5中将线程1的阻断标志更改,线程1利用阻断标志来退出线程也是ok的public class TestThreadController { public static void main(String[] args) { // 线程1 ThreadOne thread = new ThreadOne(); thread.start(); //线程5 ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,1, 60,TimeUnit.SECONDS, new SynchronousQueue<Runnable>(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.DiscardPolicy() ); threadPoolExecutor.execute(()->{ int i =0; for(;;){ if(i<10){ System.out.println("线程5执行中i: "+i); i++; }else{ thread.interrupt(); break; } } }); } } class ThreadOne extends Thread{ @Override public void run(){ while(!Thread.currentThread().isInterrupted()){ System.out.println("线程1执行中"); } } }
-
3.使用interrupt方法+异常catch来实现退出线程
调用interrupt后,调用线程会抛出阻断异常,我们可以根据这个异常抛出然后进行结束线程,这种也是一种正确的停止线程方式public class TestThread { volatile static Boolean flag = true; public static void main(String[] args) { Thread thread1 = new Thread(){ @Override public void run() { while(true){ System.out.println("线程1在运行"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); System.out.println("发生了阻断异常。。。。"); break; } } } }; Thread thread2 = new Thread(){ @Override public void run() { for (int i = 0; i < 100; i++) { if(i==50){ System.out.println("################### 线程2试图阻断线程1 ###################"); thread1.interrupt(); }else{ System.out.println("线程2在运行:"+i); } } } }; thread1.start(); thread2.start(); } }
👨🎓4.java中sleep和wait的区别
这两个方法的区别还是很大的,下面从各方面说下他们的区别
- 1.sleep是Thread的静态方法可以直接用Thread.sleep()进行调用,wait是Object中的方法,需要使用对象进行调用
- 2.sleep执行后线程是timed_wating状态,不会释放持有的锁,时间结束会自动被唤醒。wait执行后线程进入wating状态,释放持有的锁,需要手动被唤醒(使用notify,notifyall唤醒)
- 3.sleep不持有锁时可以执行,wait方法必须在持有锁时才可以执行
wait方法本质上会将持有的锁进行释放(只能持有synchronized),释放的过程就是更改对象的头部里的markword中存储的对象锁信息,若是未持有锁,在这里去更改自然就会报错了。
👨🎓5.并发编程的三大特性
这个点主要是知道并发时的三个需要面临的问题有哪三个:
- 1.原子性
原子性说的是一命令执行期间不会受其他线程影响,且执行结果要么成功要么失败。这也是我们最长说的问题,synchronized是可以解决这一问题的,其他的lock锁也是可以解决这一问题。 - 2.可见性
可见性说的多个线程同时操作一个变量时,其中一个对变量进行了改变,其他线程能否正常获取到变化后的值的问题,Volatile是可以解决这一问题的 - 3.顺序性
无论是java中的几即时编译器还是cpu都有可能对操作指令进行重拍,也就是根据我们写出来的程序,翻译出来的操作码和操作数可能与预想的不一样,这样也有可能产生线程安全问题。Volatile可以解决这一问题
👨🎓6.什么是CAS,有什么优缺点
CAS(compare and swap)它是一种乐观锁的实现原理,java中不仅提供了synchronized和lock来实现线程安全,还提供了一些工具类天然支持线程安全,比如常见的StringBuffer、HashTable等。此外java中还提供了一些使用CAS实现的Automic原子类来在多线程环境下使用:
AtomicInteger、AtomicLong、 AtomicBoolean。
-
1.什么是CAS
CAS(compare and swap)从字面上翻译CAS就是比较然后交换的意思,其实他的工作原理也就是这个样子,锁机制都是让多线程的操作变成串行化,而CAS却不是,他是先获取变量值,在需要执行变更操作时先去拿这个值与主内存的值进行比较,若是相等再将执行当前线程的操作,不等则需要重新获取然后再执行当前线程的操作,这就是CAS。必须要说的是CAS是一个原子操作,也就是说比较然后设置这个操作是不会被其他线程中断的,它线程安全,此外这种不使用锁来实现的同步机制也被称为乐观锁。相反的synchronized就是悲观锁了。 -
2.CAS的工作机制
了解了CAS,还必须要知道CAS是如何保证线程安全的,我们做个场景假设来模拟下CAS的工作流程,需要说的是这个流程是JDK8之前的,JDK8之后对CAS做了优化,但是这套机制还是适用的,JDK8只是将CAS操作变成了分段处理,每段的处理还是现在这个流程,JDK8具体的修改往下看会有介绍。下面先来假设下场景:假设有两个线程:线程一、线程二,正在同时修改AtomicInteger的值。主内存中AtomicInteger值是1。则会有如下场景发生:
①.线程一和线程二都拿到了主内存中的AtomicInteger的值是1。
②线程一想要修改AtomicInteger的值为2,修改之前先拿到自己工作内存中的1与主内存的1对比,发现相等后,将工作内存和主内存的AtomicInteger都改为了2.
③线程二此时却想要将AtomicInteger的值改为3,线程2则先拿着自己工作内存中存储的1去与工作内存中的2对比,发现不相等,不相等则不能设置,而是从新从主内存获取,获取后再次比较发现相等了,然后设置工作内存和主内存的值为3这就是CAS的工作机制的流程,因为CAS是原子操作故而保证了线程的安全。只要有一个线程在做CAS操作,那其他线程是不能进行打断的。
-
3.JDK8对CAS机制的优化 和 LongAdder
根据CAS的机制,我们可以发现当线程量十分多的时候,CAS的性能就会越来越低,因为CAS是原子操作,就会导致其他线程在不停的获取值,然后比较后发现不相等,再接着从新获取,就会陷入这样的恶性循环。因此在JDK8时对CAS机制进行了优化推出了LongAdder类,该类就是基于优化后的CAS实现的。那JDK8对CAS进行了怎样的优化呢?JDK8针对高并发场景提出了分段CAS和自动分段迁移的方式来提升高并发执行CAS时的性能。那这个分段CAS是个什么意思,自动分段迁移又是什么?来看下LongAdder的工作机制就会清楚了。先来做个场景假设有很多个线程在同时修改LongAdder的值,那么就会有如下场景发生。
①当发现有很多线程在进行CAS操作,致使很多线程出现空旋转的情况时,此时会保存一个已经计算出来的值作为base值,并且此时会创建一个cell数组,让一部分线程的计算结果存入一个cell中,这样就可以将所有线程分成好几部分来分开计算(分段CAS)。
②当有cell计算失败时,会将线程的操作自动迁移到其他cell中计算(自动分段迁移)。
③当所有线程都计算完毕后对base和cell进行合并计算得出最终结果。
这样就会提升了CAS在高并发下的效率。 -
4.为什么要对CAS进行优化
根据前面假设的场景可以发现CAS在高并发的场景下会让大量线程出现空旋转的情况,从而出现影响性能的情况。因而在JDK8时才对CAS进行优化,新增了LongAdder类。LongAdder的实现机制就是分段CAS+自动分段迁移。这样就大大提高了在多线程场景下的效率,当然了若是线程量比较小的场景我们还是使用原子类AtomicInteger等类即可。无需使用LongAdder。若是了解JDK8中提供的流式操作的同学可能会比较熟悉这个场景,流式操作的底层也是会对流进行分段处理,这其实是一种很常见的并发处理思想,同时也多处用于提升处理效率。 -
5.已经有锁了,为什么还要CAS机制
前面已经说过,synchronized是一种悲观锁,CAS机制则被认为一种乐观锁。悲观锁可以支持代码块、方法级别的同步,自然也是可以在包装的情况下修改字段的值,而乐观锁主要强调的是对单一变量的修改,他们的侧重点不一样,并且在单一变量的修改场景使用悲观锁的代价太高,悲观锁所耗费的虚拟机性能要高出很多。所以才有了CAS的生存空间。 -
AtomicInteger使用示例
下面只是一个假设的场景,主要是为了验证AtomicInteger的安全性,代码如下:import java.util.concurrent.atomic.AtomicInteger; /** * @author pcc * @version 1.0.0 * @className TestThread * @date 2021-06-28 16:33 */ public class TestThread { static AtomicInteger atomicInteger = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException{ for (int i1 = 0; i1 < 20000; i1++) { new Thread(() -> { System.out.println(Thread.currentThread().getName()+"线程正在操作+1"); atomicInteger.addAndGet(1); }).start(); } Thread.sleep(1000); System.out.println(atomicInteger.toString()); } }
👨🎓7.Contended注解有什么用
该注解是Java8中新增加的一个注解,主要应用场景就是在CAS中,比如CurrentHashMap的CounterCell和LongAdder中的Cell都是被该注解修饰的。那该注解到底是什么用处呢,其实该注解主要使用的应用原理是为了减少工作内存从主内存同步数据的频率,CAS在加锁过程中需要频繁的从工作内存中获取数据与主内存数据进行对比,这个过程是很耗费cpu性能的。而Contended注解就是为了减少这一过程,从而达到提升CAS效率的一个目的。那原理上Contended是怎么做的呢。Contentded将从主内存通过过来的数据补充7个假的数据(缓存行64字节一行,一个long是8位,所以补充7个long的假数据)这样就减少了一个缓存行中数据从主内存中同步数据的频率。从而实现了对CAS性能的提升,不过Contentded只是用来提升long类型存储和计算的效率。其他类型暂还不支持。
👨🎓8.java中四种引用类型有哪些
java中有强、软、弱、虚四种引用数据类型,详细了解可以参考笔者的一篇专门介绍引用的文章:java中的强引用、软引用、弱引用、引用用。他们的区别其实主要体现在两个方面,一个是创建方式,一个是回收方式,下面从这两个方面介绍下这四种引用数据类型。
-
强引用
我们通过正常new出来的对象无论是成员变量还是局部变量都是强引用(创建方式),强引用的回收完全依赖于可达性分析算法,当对象在GCRoots间有引用链时就不会被判定为垃圾,若无则会被判定垃圾,在下一次GC时进行回收(回收方式) -
软引用
软引用的声明需要借助SoftReference来进行声明(创建方式),同时软引用会在jvm下一次GC时进行尝试回收,注意只是尝试回收并不会一定回收,真正呢能不能回收还是根据可达性分析算法来判定,可达依然不会回收(回收方式)。 -
弱引用
弱引用的声明需要借助WeakReference来进行声明(创建方式),弱引用的对象通常熬不过一次垃圾收集,jvm会在gc时对弱引用进行直接回收,不过不一定全部会回收,但会尝试回收且无需判定可达,直接判定为垃圾,典型的应用是java里的ThreadLocalMap的key就是弱引用(回收方式)
-
虚引用
虚引用的创建同样需要依赖第三者类,依赖的是PhantomReference,同时还需要为虚引用提供一个引用队列来存储虚引用。(创建方式)此外虚引用创建即销毁,是查询不到该引用的存在的,据说这种引用类型就是为了观察对象的创建和销毁过程而存在的实际没啥用。(回收方式)
👨🎓9.ThreadLocal的内存泄露问题只有value吗?
这个问题一般都会说value的内存泄露问题,其实这个问题可以聊一聊key的泄露和value的泄露。先来回忆下ThreadLocal的实现原理:
实际上我们在线程内部使用ThreadLocal存储对象时,他的对象时存储在ThreadLocalMap中的,事实上每个线程都有一个独立的ThreadLocalMap,每个线程有且仅有一个ThreadLocalMap,无论有多少ThreadLocal操作数据都会被存入到线程中这个独有的ThreadLocalMap,且每个ThreadLocal其实只能存储一个值,因为ThreadLocal会被作为key存放到ThreadLocalMap中,key的位置采用hash值进行计算,key的话就是ThreadLocal,value的话就是我们存入的值,key值得说的是他是一个weakreference,在下一次GC时会被回收。假设有如下一个场景:两个ThreadLocal,一个线程内部的ThreadLocalMap存储了两个ThreadLocal作为key的entry。我们来分析下key和value的内存泄露问题
- key的泄露问题
key的泄露其实并不会很严重,java已经使用WeakReference来规避了这个场景,所以在使用弱引用作为key其实就已经解决了这个问题,那这个问题是怎么产生的呢?当ThreadLocal失去了引用之后,理论上他就不可达了,会被判定为垃圾。但是此时ThreadLocalMap仍然可达,所以ThreadLocal也就不会被回收,因为ThreadLocalMap的key是ThreadLocal,所以ThreadLocal仍然有一条引用链存在,这就导致了ThreadLocal并不会被正常回收,这一过程将会持续到线程结束才会被正常回收。java解决这个问题就是引用了弱引用来作为key就解决了这个问题。当ThreadLocal失效以后,key因为是弱引用所以被收回,这样就避免了key的内存泄露问题。那value呢?此时key内存泄露,value自然是内存泄露的。 - value的泄露问题
要解决value的内存泄露问题,其实需要我们手动处理,也就是ThreadLocal使用完毕之后手动调用他的remove方法即可,他会删除entry,从而避免了内存泄露。 - ThreadLocal的实际应用:ReentrantReadWriteLock
ReentrantReadWriteLock中的写锁是互斥锁和普通锁区别不大,但是读锁是共享锁,而且是可重入锁,利用AQS的state显然满足不了这个实现。事实上state高位是用以记录读锁的持有状态的(前16位)低位(后16位)是记录写锁的,当一个线程想要进行对读操作重复进行加锁时,就会发现无法知道自己重入了多少次(持有锁的线程会很多,无法根据持有锁的线程判断),此时java采用每个线程都用Threadlocal维护自己持有锁的次数,从而解决了读锁的重入问题。
👨🎓10.java中的锁分类
这个问题答案就很多了,可以从各个角度说下锁的分类
- 从锁的重量来划分
重量级锁(1.6之前):synchronized
jdk1.6之前synchronized实现完全是依赖对象头中的markword中存储的锁来判断加锁,一旦加锁成功所有线程均需等待。
轻量级锁:lock
lock是1.5引入,底层采用AQS的模式来进行加锁,AQS地产是CAS机制,并不会直接上锁,而是对数据进行一个判断后再进行操作,其他线程过来并不会第一时间进行上锁,大家都是先沟通再操作。 - 从锁是否可重入来划分
可重入锁:ReentrantLock
因为底层是AQS,借助了AQS中的state来标注加锁状态,0未加锁,大于0则是加锁,从而实现可重入
不可重入锁:synchrozed
锁一旦加上则其他任何线程禁止执行 - 从锁的公平性来划分
公平锁:创建ReentrantLock时传入true则是公平锁
公平锁加锁时会维护一个队列,等待线程会进入队列排队,加锁是根据队列中先入先加来加锁的,公平锁可以解决线程饥饿问题
非公平锁:其余场景包括synchronized都是非公平锁
非公平锁可能会造成线程饥饿 - 从锁的实现原理划分
乐观锁:使用AQS实现的如:Reentrant是乐观锁
悲观锁:1.6之前的Synchrozed
因为1.6只有java对synchronized进行了优化,他的底层开始是偏向锁,偏向锁底层是CAS,也是一种乐观锁的实现。 - 从锁的互斥性来划分
互斥锁:ReentrantReadWriteLock.writeLock 写锁
写锁排期其他线程的读行为和写行为,对其他线程互斥
共享锁:ReentrantReadWriteLock.readLock 读锁
读锁不排斥其他线程的读行为,但不能写,是一种共享锁
👨🎓11.Synchronized在1.6的优化
java1.6开始对Synchronized进行了三块优化,这也就是上面为什么说java1.6之前synchronized是重量级锁是悲观锁的原因,因为1.6做了优化之后这个已经变了。
- 锁消除
1.6开始java中JIT会帮助判断加锁的场景是否真有并发场景产生,若是判断这个场景没有并发,JIT则会将加锁取消,这个场景会存在误判的可能。 - 锁膨胀
锁膨胀就是将锁的范围进行扩大,一般是指JIT在编译Java代码时,将多个连续的锁操作合并成一个更大的锁操作。这种优化可以避免在循环中反复进行锁操作,从而减少锁竞争的开销,提高程序的性能。 - 锁粗化
1.6之后使用Synchronized会有这样一个过程:无锁–>偏向锁–>轻量级锁–>重量级锁,锁在这个过程中会不断进行粗化,越来越重知道最后使用synchronized原始的加锁方式,这个过程被称为锁粗化的过程。粗粗化过程:初始状态是不加锁,当有一个线程过来尝试加锁时会进行升级到偏向锁,偏向锁底层是CAS,当有另一个线程也尝试进行加锁时,会先判断加锁线程是不是持有偏向锁的线程,如果是可以继续执行,不是的话,则会触发锁升级,此时会升级为轻量级锁。synchronized为轻量级锁时表示锁存在多个资源在进行竞争,当竞争不到锁的时候,线程会进入自旋状态,也就是我们常说的自旋锁,所以轻量级锁也叫自旋锁(适应性自旋锁),这个状态下未抢占到锁的线程会进入空旋转,而不是直接进入到阻塞状态,当锁旋转一定周期后(周期初始是10,会自动调整),仍然获取不到锁,会对自旋进行调整,然后对锁进行升级,此时锁就会进入重量级锁的状态,重量级锁使用的就是java1.6之前的实现方式,只有一个线程能获取到锁,其他线程阻塞。
上图是对象的一个简略信息,锁信息便是存储在对象头中,下图是锁的几种状态在对象头中的体现。
👨🎓12.Synchronized实现的原理
上面已经介绍了synchronized的原理,这里就不重复说了
👨🎓13.什么是AQS
AQS(AbstractQueuedSynchronizer)抽象队列同步器,他是JUC中提供的一个工具类,可以帮助我们实现加锁。java中的ReentrantLock、ReentrantReadWriteLock、ThreadPoolExecutor都使用了AQS来实现自己的锁机制,举一个ReentrantLock的例子来说明AQS实现锁的过程。AQS中使用CAS 修饰的int类型的变量state来标识锁的状态,state为0表示无锁,大于0表示有锁,使用exclusiveOwnerThread来标识持有锁的线程,使用自身实现的Node双向链表结构的数据结构来存储等待线程等。当一个线程尝试加锁时会先去检查state,为0的话则直接加锁,大于0的话则比对exclusiveOwnerThread是否是自己,若不是则加锁失败进入等待队列(一个双向链表),若是自己则state加1,此时就是可重入锁,可重入锁不会有死锁的风险。
需要说的是AQS唤醒双向链表中的等待结点(线程)时是从后往前找的,这个原因需要我们看下等待任务进入双向链表时的动作,此时他是在尾部进行插入,插入时是先将尾部的上一结点(原来的尾结点)维护好,下一结点(头结点)是后置动作,所以从后往前是更好的选择。
👨🎓14.ReentrantLock与Synchrozed的异同
可以从两方面对比下他们,实现原理与使用区别来进行阐述:
- 原理上的区别
synchronized持有的锁是类锁或者对象锁,锁信息均存储在对象头中(1.6的偏向锁、轻量级锁虽然底层是CAS但依然存储在对象头),使用时无需手动释放。ReentrantLock底层是AQS,是一种乐观锁实现,加锁过程可见,是一种显示锁,锁信息通过AQS中的state与exclusiveOwnerThread来进行储存,且锁可实现重入。 - 使用上的区别
synchronized可以用来修饰代码块,方法、静态方法,当修饰静态方法或者传入的是clazz对象时,锁住的是整个类的同步方法,且synchronized在1.6之后做了锁膨胀和粗化的调整。性能上和ReentrantLock非常接近,几无区别。ReentrantLock支持乐观和非乐观两种实现,且支持重入,加锁过程更加灵活。
👨🎓15.ReentrantReadWriteLock的原理
ReentrantReadWriteLock分为读锁和写锁,且读锁和写锁具有明显的区别:读锁是共享锁,写锁是互斥锁。也就是说读锁可以实现多线程同时读,但不支持写,写锁对其他读与写均互斥。那他们的实现原理都是怎么样的呢?
- 读锁:ReentrantReadWriteLock.readLock readLock= new ReentrantReadWriteLock().readLock();
底层都是AQS所以实现锁的原理大致上差不多,不同点是读锁的仅仅使用state的高16位,写锁是占用低16位,这里需要说的是读锁的可重入是怎么实现的。AQS通过int类型变量state来维护锁的状态以及标识锁重入的次数,这是对于一个线程重入时可标注,对于多个线程时就不好标注了,因为不知道一个线程的重入次数。为了解决这一个问题,ReentrantReadWriteLock在每个加锁的线程内都会通过ThreadLocal维护一个重入次数用来标识当前线程的重入次数。从而实现了读锁的可重入 - 写锁:ReentrantReadWriteLock.writeLock writeLock = new ReentrantReadWriteLock().writeLock ();
写锁则不需要多说了,他的原理就是AQS与ReentrantLock没啥变化。 - 读锁的写线程饥饿问题
既然读读操作可以重复加锁,就很容易造成写线程的超时等待,也就是常说的线程饥饿问题,那怎么解决这一问题呢,实际上是运用了一个队列,运用先进先得的理念来进行加锁的,若是加锁动作轮到写线程时会等待读操作完成然后由写线程进行加锁,此时读锁对其他写和读互斥。
👨🎓16.JDK提供了哪些线程池
这个问题一般初中级可能会比较多些,只是考察对线程池的初步了解,因为一般不允许使用java自带的线程池,所以这里只是为了引出怎么来自定义线程池的。
- 定长线程池
Executors.newFixedThreadPool(1);定长线程就是传入一个线程数用他来创建一个线程池,这里的线程数代表的既是核心线程也是最大线程,同时其他参数均使用默认,需要关注的是默认的线程工程、默认的阻塞队列,默认的拒绝策略。默认的线程工程这里是DefaultThreadFactory,就是叫做默认线程工程的一个线程工厂,阻塞队列是LinkedBlockingQueue,一个链表结构的阻塞队列,默认的拒绝策略是AbortPolicy,当阻塞队列满了以后若是还有任务进来就会报异常。 - 单例线程池
Executors.newSingleThreadExecutor();单例线程池顾名思义就是只有一个线程的线程池,其实定长线程池限制个数为1时就是单例了。其他的线程工程、阻塞队列,拒绝策略等都和定长线程池没有任何区别。 - 缓存线程池
Executors.newCachedThreadPool();缓存线程池,不存在核心线程,所有的线程都是非核心线程,线程数会根据任务的多少进行创建,且每个线程的等待时间是60s,超过60s无任务就会销毁,这种就是缓存线程池。他的线程工厂拒绝策略和定长线程池都没有区别,只有阻塞队列使用的是自己独有的SynchronousQueue,官方注解这么说,他是一个非公平的阻塞队列,为什么这么说呢,因为底层用的是一个栈来处理阻任务的,栈的特点先进后出,自然就是非公平的了,可能会造成部分任务的饥饿。不过话说回来,因为缓存线程池未设置最大线程说,他可能接到任务就会创建线程,这样会导致CPU的压力陡增。 - 定时线程池
Executors.newScheduledThreadPool(1);他的实现与上面几种都不同,他使用的是ScheduledThreadPoolExecutor来进行创建的,这个线程池执行器也具有7个参数,7个参数类型和上面也完全相同,其中线程工程、拒绝策略等和定长都是一样的,只有阻塞队列是特有的,这里传入的阻塞队列是延时阻塞队列,定时线程池就是通过维护一个延迟队列来实现任务的调度。任务按照执行时间排序,定期(另起线程)从队列中获取已到期的任务,并将它们放到线程池中执行。这种实现方式可以很好地支持定时执行任务的需求,并且具有高效、可靠的特性
👨🎓17.线程池的核心参数有什么
这个就来说一说线程池的核心参数有哪些,其中大部分参数都很重要,核心线程数和最大线程数很重要不过也很好懂,像线程工厂、拒绝策略、阻塞队列都很重要却很多人说不明白,这个才是面试的高频疑问点。
- 核心线程数
用来声明线程池的核心线程数,核心线程的特点是线程池销毁前不会消亡。 - 最大线程数
最大线程数是指含核心线程数在内最多可以创建多少个线程,线程池做任务调度时会先看有无空闲核心线程,没有聚会看是否达到最大线程,没达到就会创建新线程了 - long类型失效时间
管控非核心线程的失效时间,这里是long类型的失效时间 - 失效时间单位
这里是非核心线程失效时间的单位,使用TimeUnit,支持时,分,秒,毫秒、微妙,纳秒等 - 线程工程
这个参数就很重要了,他的作用其实就是为了给线程池提供线程的,无论是我们的核心线程还是非核心线程都是需要通过线程工厂来创建,那我们来看下默认的线程工厂DefaultThreadFactory是如何创建线程的。
当有任务过来时线程池会调用线程工厂的newThread方法,可以看到这个线程创建很简单,创建时传入线程组,Runnable实现类,还有线程名,就ok了,里面也很简单就是将线程设置为非守护线程,线程优先级设置为默认层级。可以看到这个实现其实很简单,所以我们完全可以根据自己的需求来实现线程工厂,比如自定义线程名、守护线程、优先级等,此外java也提供了一些常用的线程工厂,比如public Thread newThread(Runnable r) { Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); if (t.isDaemon()) t.setDaemon(false); if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); return t; }
NamedThreadFactory:这是一个比较常用的自定义线程工厂实现类,它可以根据指定的线程名前缀创建线程,并且支持设置线程的优先级和是否为守护线程。
CustomThreadFactory:这是一个比较通用的自定义线程工厂实现类,它提供了比较灵活的线程创建方式,可以自定义线程的一些属性,例如线程名、优先级等等。通常情况下,我们可以根据业务需求自定义这个线程工厂。
如果没有特殊需求,使用默认即可,如有建议自己实现线程工厂只需要实现ThreadFactory然后实现方法newThread即可,下面是一个自己实现的简易线程工厂class MyThreadFactory implements ThreadFactory{ volatile Integer num=0; @Override public Thread newThread(Runnable r) { num++; Thread t = new Thread(r,"我的线程"+num); if(t.isDaemon()){ t.setDaemon(true); } if(t.getPriority()==5){ t.setPriority(5); } return t; } }
- 阻塞队列
这个参数同样也很关键,他的作用是当我们线程数已经达到最大线程数,且还有任务不断在提交过来时,此时任务就会进入到阻塞队列中。阻塞队列常见的就有很多,定长线程是使用的是LinkedBlockingQueue,这个阻塞队列就是一个普通无界的单项链表任务进来以后,将新的任务在尾部进行插入然后等待任务调度即可,缓存线程池使用的是SynchronousQueue,默认情况下他使用的是栈进行实现阻塞队列无容量常用来实现线程通信,栈的特性就是先进后出,所以他有个不公平的特性在,可能会造成先进入队列里面的任务的饥饿。单例线程池使用的是DelayedWorkQueue,这是一个延时队列也无界,存入其中的任务都会封装成ScheduledFutureTask的对象,从而具有了可运行可调度的特性,然后会有另一个线程根据延时时间对任务进行调度执行,从而实现了定时的特性。此外java常用的还有ArrayBlockingQueue他和LinkedBlockingQueue很是相似,区别就是ArrayBlockingQueue是有界的,若是可以确定任务数能评估号可以使用ArrayBlockingQueue,若是任务不定,使用ArrayBlockingQueue可能会造成队列占满,造成线程阻塞,此时若是拒绝策略选用不当就会造成问题了。 - 拒绝策略
拒绝策略也很关键,他的作用是当阻塞队列也满了的时候,新进的任务该如何处理。默认的拒绝策略是AbortPolicy,他的策略是阻塞队列满了我就抛异常,其他不做操作,此外java也提供了几种常见的拒绝策略,如下:
DiscardPolicy:直接丢弃,啥也不做,这种不推荐使用
DiscardOldestPolicy:丢弃最老的任务,也不推荐使用
AbortPolicy:这是默认的策略就是抛异常,也不推荐
以上是java提供的几种拒绝策略,其实我们完全可以自己实现拒绝策略,我们只需要实现RejectedExecutionHandler重写rejectedExecution即可。假如我们使用的阻塞队列是ArrayBlockingQueue,当队列满的时候,我们自己实现拒绝策略,就是可以让任务入库,或者为数组进行扩容,比如模仿ArrayList的扩容方式一次增加1\2,这样也是可以的。默认的拒绝策略在我们使用无界的阻塞队列时一般不会有事,但一旦使用有界的阻塞队列,就需要考虑自己实现拒绝策略了。
下面是AbortPolicy的拒绝实现public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException("Task " + r.toString() + " rejected from " + e.toString()); }
👨🎓18.线程池的状态
线程有自己的状态,同样的线程池也是有自己的状态的,那线程池都有哪些呢?
RUNNING:正在运行,会处理正在进行中的任务、阻塞队列中的任务,也会接收新任务
SHUTDOWN:正在运行,会处理正在进行中的任务、阻塞队列中的任务,不会接收新任务,执行shutdown方法后会进入该状态
STOP:停止了,正在运行的任务中断,队列中的任务不会执行,也不会接收新的任务,执行shutdownnow方法进入该状态
TIDYING :这是一个过渡状态,shutdown和stop转换过来,即将接近死亡态
TERMINATED:线程到这个状态时就需要执行terminated方法了,执行完该方法线程池就就会消亡,不过该方法默认是空方法,需要我们自己写逻辑,也可以不写。
下图是线程池各个状态的切换过程,正常情况下我们关闭线程池应该使用shutdown而不是shutdownnow
👨🎓19.线程池的执行流程
下图是一张比较完整的线程池的工作流程,其实就是execute方法的源码得解析:
- 简略流程
任务进来,先看核心线程是否空闲,空闲就会提交给核心线程执行,否则线程池查看最大线程数是否满足,不满足时创建非核心运行,若是线程数已达最大,则将任务进入阻塞队列,若是阻塞队列也满了则会执行拒绝策略,根据不同的拒绝策略执行不同的处理逻辑。
👨🎓20.线程池添加工作线程的流程
-
第一步执行execute时先判断核心线程是否有空闲,有的话直接调用addWorker方法
int c = ctl.get(); if (workerCountOf(c) < corePoolSize) {//工作线程小于核心线程 if (addWorker(command, true))//调用addWorker方法 return; c = ctl.get(); }
-
第二步,进入addWorker方法后先判断线程池状态,正常后对ctl(存储线程个数的)的高位进行CAS+1操作。
-
第三步,传入execute传过来的firsttask也就是Runnable或者Callable的实现类,进行初始化一个Worker,他的构造器中会利用线程工厂的newThread方法进行创建线程。
-
第四步,调用Worker的Thread的实例,最后进行Thread.start操作,实现启动线程进行任务执行。
线程池通过以上一步
线程池通过这四步实现了对工作流程的添加。
👨🎓21.线程池为何要构建空任务的非核心线程
若是核心线程数设置的为0,我们第一次执行addWorker时,就会因为核心线程和工作线程都是0,二不会执行第一块标红的区域,而是会执行第二块,而第二块是直接将任务添加到阻塞队列里面,此时是没有工作线程的,那阻塞队列里的任务由谁执行呢?所以在线程池的状态正常的情况下会添加一个空任务用于执行阻塞队列中的任务。
👨🎓22.线程池使用完为何必须shutdown
因为不使用shutdown线程池就基本不会被正常回收,所以线程池使用完毕后应该使用shutdown或者shutdownnow方法,这样才能保证线程的回收,不会造成内存泄露,那使用shutdown是怎么做到让线程回收的呢,其实根本原因是因为核心线程不会被回收,而核心线程的引用链中有线程池,所以线程池就不会被回收。当执行shutdown方法时会将任务执行完毕后对核心线程进行执行完毕,就是调用处理完核心现成的run方法,实现线程的结束。
👨🎓23.线程池的核心参数到底如何设置
这里首先需要判断任务是IO密集型还是CPU密集型或者是混和型(IO和CPU差不多),若是IO密集型我们一般可以设置核心线程或者最大线程是CPU核心数的两倍,若是CPU密集型的话,一般线程数设置为CPU核心数即可。对于混和型就需要将线程数在CPU核心数与CPU核心数2倍之前寻找一个平衡点了。
上面说的都是方法论,真正的业务时,线程数应该怎么设置,不应该是完全根据CPU的核心数或者2倍数来定的。这种方法只是提供一个初始化的数据,然后在压测或者环境模拟中来寻找真正合适的线程数,这次是正确的操作。
- 为什么不设置为服务器的线程数而是CPU的核心数?
因为线程的切换对于CPU来说资源消耗还是很多的,所以一般CPU密集型都是设置为CPU的核心数,而不是支持的线程数