这个问题的答案可以根据工作年限的不同来回答了,若是工作五年以下面试初中级开发时,可以回答三种或者四种都是可以的,哪三种呢?就是下面三种了,若是面试高开最好回答是一种,我们需要从原理上来解释下为什么是一种
public class Test1 extends Thread{@Overridepublic void run() {while(true){System.out.println("线程1");}}public static void main(String[] args) {new Test1().start();}
}
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();}
}
public class TestThread {public static void main(String[] args) throws Exception{FutureTask futureTask = new FutureTask<>(()->{int i =0 ;while(i<100)System.out.println("Callable线程1在执行:"+i++);return "线程1执行完了";});FutureTask 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接口:
线程状态我们常说的有这几种:新建(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:* * - {@link Object#wait() Object.wait} with no timeout
* - {@link #join() Thread.join} with no timeout
* - {@link LockSupport#park() LockSupport.park}
*
** A thread in the waiting state is waiting for another thread to* perform a particular action.** For example, a thread that has called Object.wait()* on an object is waiting for another thread to call* Object.notify() or Object.notifyAll() on* that object. A thread that has called Thread.join()* 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:*
* - {@link #sleep Thread.sleep}
* - {@link Object#wait(long) Object.wait} with timeout
* - {@link #join(long) Thread.join} with timeout
* - {@link LockSupport#parkNanos LockSupport.parkNanos}
* - {@link LockSupport#parkUntil LockSupport.parkUntil}
*
*/TIMED_WAITING,/*** Thread state for a terminated thread.* The thread has completed execution.*/TERMINATED;}
这才是java给线程定义的官方状态,是6种,下面列些各个状态的区别:
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 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) {// 线程1ThreadOne thread = new ThreadOne();thread.start();//线程5ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1,1, 60,TimeUnit.SECONDS, new SynchronousQueue(),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{@Overridepublic 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(){@Overridepublic 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(){@Overridepublic 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();}
}
这两个方法的区别还是很大的,下面从各方面说下他们的区别
这个点主要是知道并发时的三个需要面临的问题有哪三个:
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());}}
该注解是Java8中新增加的一个注解,主要应用场景就是在CAS中,比如CurrentHashMap的CounterCell和LongAdder中的Cell都是被该注解修饰的。那该注解到底是什么用处呢,其实该注解主要使用的应用原理是为了减少工作内存从主内存同步数据的频率,CAS在加锁过程中需要频繁的从工作内存中获取数据与主内存数据进行对比,这个过程是很耗费cpu性能的。而Contended注解就是为了减少这一过程,从而达到提升CAS效率的一个目的。那原理上Contended是怎么做的呢。Contentded将从主内存通过过来的数据补充7个假的数据(缓存行64字节一行,一个long是8位,所以补充7个long的假数据)这样就减少了一个缓存行中数据从主内存中同步数据的频率。从而实现了对CAS性能的提升,不过Contentded只是用来提升long类型存储和计算的效率。其他类型暂还不支持。
java中有强、软、弱、虚四种引用数据类型,详细了解可以参考笔者的一篇专门介绍引用的文章:java中的强引用、软引用、弱引用、引用用。他们的区别其实主要体现在两个方面,一个是创建方式,一个是回收方式,下面从这两个方面介绍下这四种引用数据类型。
强引用
我们通过正常new出来的对象无论是成员变量还是局部变量都是强引用(创建方式),强引用的回收完全依赖于可达性分析算法,当对象在GCRoots间有引用链时就不会被判定为垃圾,若无则会被判定垃圾,在下一次GC时进行回收(回收方式)
软引用
软引用的声明需要借助SoftReference来进行声明(创建方式),同时软引用会在jvm下一次GC时进行尝试回收,注意只是尝试回收并不会一定回收,真正呢能不能回收还是根据可达性分析算法来判定,可达依然不会回收(回收方式)。
弱引用
弱引用的声明需要借助WeakReference来进行声明(创建方式),弱引用的对象通常熬不过一次垃圾收集,jvm会在gc时对弱引用进行直接回收,不过不一定全部会回收,但会尝试回收且无需判定可达,直接判定为垃圾,典型的应用是java里的ThreadLocalMap的key就是弱引用(回收方式)
虚引用
虚引用的创建同样需要依赖第三者类,依赖的是PhantomReference,同时还需要为虚引用提供一个引用队列来存储虚引用。(创建方式)此外虚引用创建即销毁,是查询不到该引用的存在的,据说这种引用类型就是为了观察对象的创建和销毁过程而存在的实际没啥用。(回收方式)
这个问题一般都会说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的内存泄露问题
这个问题答案就很多了,可以从各个角度说下锁的分类
java1.6开始对Synchronized进行了三块优化,这也就是上面为什么说java1.6之前synchronized是重量级锁是悲观锁的原因,因为1.6做了优化之后这个已经变了。
上面已经介绍了synchronized的原理,这里就不重复说了
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唤醒双向链表中的等待结点(线程)时是从后往前找的,这个原因需要我们看下等待任务进入双向链表时的动作,此时他是在尾部进行插入,插入时是先将尾部的上一结点(原来的尾结点)维护好,下一结点(头结点)是后置动作,所以从后往前是更好的选择。
可以从两方面对比下他们,实现原理与使用区别来进行阐述:
ReentrantReadWriteLock分为读锁和写锁,且读锁和写锁具有明显的区别:读锁是共享锁,写锁是互斥锁。也就是说读锁可以实现多线程同时读,但不支持写,写锁对其他读与写均互斥。那他们的实现原理都是怎么样的呢?
这个问题一般初中级可能会比较多些,只是考察对线程池的初步了解,因为一般不允许使用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;}
当有任务过来时线程池会调用线程工厂的newThread方法,可以看到这个线程创建很简单,创建时传入线程组,Runnable实现类,还有线程名,就ok了,里面也很简单就是将线程设置为非守护线程,线程优先级设置为默认层级。可以看到这个实现其实很简单,所以我们完全可以根据自己的需求来实现线程工厂,比如自定义线程名、守护线程、优先级等,此外java也提供了一些常用的线程工厂,比如 class MyThreadFactory implements ThreadFactory{volatile Integer num=0;@Overridepublic 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;}
}
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {throw new RejectedExecutionException("Task " + r.toString() +" rejected from " +e.toString());}
线程有自己的状态,同样的线程池也是有自己的状态的,那线程池都有哪些呢?
RUNNING:正在运行,会处理正在进行中的任务、阻塞队列中的任务,也会接收新任务
SHUTDOWN:正在运行,会处理正在进行中的任务、阻塞队列中的任务,不会接收新任务,执行shutdown方法后会进入该状态
STOP:停止了,正在运行的任务中断,队列中的任务不会执行,也不会接收新的任务,执行shutdownnow方法进入该状态
TIDYING :这是一个过渡状态,shutdown和stop转换过来,即将接近死亡态
TERMINATED:线程到这个状态时就需要执行terminated方法了,执行完该方法线程池就就会消亡,不过该方法默认是空方法,需要我们自己写逻辑,也可以不写。
下图是线程池各个状态的切换过程,正常情况下我们关闭线程池应该使用shutdown而不是shutdownnow
下图是一张比较完整的线程池的工作流程,其实就是execute方法的源码得解析:
第一步执行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操作,实现启动线程进行任务执行。
线程池通过以上一步
线程池通过这四步实现了对工作流程的添加。
若是核心线程数设置的为0,我们第一次执行addWorker时,就会因为核心线程和工作线程都是0,二不会执行第一块标红的区域,而是会执行第二块,而第二块是直接将任务添加到阻塞队列里面,此时是没有工作线程的,那阻塞队列里的任务由谁执行呢?所以在线程池的状态正常的情况下会添加一个空任务用于执行阻塞队列中的任务。
因为不使用shutdown线程池就基本不会被正常回收,所以线程池使用完毕后应该使用shutdown或者shutdownnow方法,这样才能保证线程的回收,不会造成内存泄露,那使用shutdown是怎么做到让线程回收的呢,其实根本原因是因为核心线程不会被回收,而核心线程的引用链中有线程池,所以线程池就不会被回收。当执行shutdown方法时会将任务执行完毕后对核心线程进行执行完毕,就是调用处理完核心现成的run方法,实现线程的结束。
这里首先需要判断任务是IO密集型还是CPU密集型或者是混和型(IO和CPU差不多),若是IO密集型我们一般可以设置核心线程或者最大线程是CPU核心数的两倍,若是CPU密集型的话,一般线程数设置为CPU核心数即可。对于混和型就需要将线程数在CPU核心数与CPU核心数2倍之前寻找一个平衡点了。
上面说的都是方法论,真正的业务时,线程数应该怎么设置,不应该是完全根据CPU的核心数或者2倍数来定的。这种方法只是提供一个初始化的数据,然后在压测或者环境模拟中来寻找真正合适的线程数,这次是正确的操作。
下一篇:Java基础语法-运算符