1. 进程和线程有什么区别?
进程是系统进行资源分配和调度的最小单位。
线程是CPU进行调度的最小单位,一个进程可以包含多个线程。
比如在Java中,当我们启动 main 函数其实就启动了一个JVM进程,而 main 函数在的线程就是这个进程中的一个线程,也称主线程。
*2. 进程创建有哪些方式?
继承Runnable接口,重写run()方法(推荐)
1 2 3 4 5 6 7 8 9 public class RunnableTask implements Runnable { public void run() { System.out.println("Runnable!"); } public static void main(String[] args) { RunnableTask task = new RunnableTask(); new Thread(task).start(); } }
1 2 3 4 5 6 7 8 9 10 11 12 public class ThreadTest { public static class MyThread extends Thread { @Override public void run() { System.out.println("This is child thread"); } } public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); } }
这里的start()方法和主线程是交替执行的,通过调用Thread类的 start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到CPU时间片,就开始执行run()方法。
上面两种都是没有返回值的,但是如果我们需要获取线程的执行结果,可以实现Callable接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class CallerTask implements Callable<String> { public String call() throws Exception { return "Hello,i am running!"; } public static void main(String[] args) { FutureTask<String> task=new FutureTask<String>(new CallerTask()); new Thread(task).start(); try { String result=task.get(); System.out.println(result); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }
3. 什么是CAS算法? CAS(compare and swap,比较并且交换)算法是通过非阻塞方式避免多线程安全的一种方式,属于乐观锁相关的技术。
简单来说,它维护了三个变量,旧的预期值A、当前内存值V、即将更新的值B,通过while循环不断获取内存中的数值比较并更新。
同时,CAS也存在着一些问题:
ABA问题。当且仅当内存值V等于旧的预期值A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。那么如果先将预期值A给成B,再改回A,那CAS操作就会误认为A的值从来没有被改变过,这时其他线程的CAS操作仍然能够成功,但是很明显是个漏洞,因为预期值A的值变化过了。
在Java并发包中,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性,即在变量前面添加版本号,每次变量更新的时候都把版本号+1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
CPU消耗过高的问题,while循环时间过长会极大消耗CPU的性能。
当某一方法比如:getAndAddInt()执行时,如果CAS失败,会一直进行尝试。如果CAS长时间尝试但是一直不成功,可能会给CPU带来很大的开销。
只能保证一个共享变量的原子操作。
当操作1个共享变量时,我们可以使用循环CAS的方式来保证原子操作,但是操作多个共享变量时,循环CAS就无法保证操作的原子性,这个时候就需要用锁来保证原子性。
在多线程环境中,其他线程可能在循环CAS进行更新操作之前修改了其他共享变量的值,从而导致循环CAS的更新操作失效。
4. Thread.sleep(0)会发生什么? 首先,sleep()指定毫秒数后,是不一定会在指定的毫秒数后立即执行的,关键至于是否分配到了CPU时间片。
这里,就需要区分Windows和Unix操作系统了。Unix操作系统使用的是时间片算法,Windows操作系统使用的是抢占式算法。我们现在基于Unix操作系统来讨论。
假如我们调用Thread.sleep(1000),代表在未来1000毫秒内不参与时间片的竞争,但是在1000毫秒后,如果有优先级更高的线程,那么我们这个休眠了1000毫秒的线程依然不会执行。
而Thread.sleep(0)的作用看上去只是在0毫秒内不参与时间片的竞争,好像写不写没有什么区别,其实不是这样的。Thread.Sleep(0)的作用,就是“触发操作系统立刻重新进行一次CPU竞争”。竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。这也是我们在大循环里面经常会写一句Thread.Sleep(0) ,因为这样就给了其他线程比如Paint线程获得CPU控制权的权力,这样界面就不会假死在那里。
所谓抢占式操作系统,就是说如果一个进程得到了 CPU 时间,除非它自己放弃使用 CPU ,否则将完全霸占 CPU 。但这个行为仍然是受到制约的——操作系统会监控你霸占CPU的情况,如果发现某个线程长时间霸占CPU,会强制使这个线程挂起,因此在实际上不会出现“一个线程一直霸占着 CPU 不放”的情况。
但这个行为仍然是受到制约的——操作系统会监控你霸占CPU的情况,如果发现某个线程长时间霸占CPU,会强制使这个线程挂起,因此在实际上不会出现“一个线程一直霸占着 CPU 不放”的情况。因此反应到界面上,看起来就好像这个线程一直在霸占着CPU一样。
5. 线程有哪些常用的调度方法? 等待:wait()、wait(long timeout)、join()
通知:notify()、notifyAll()
让出优先权:yield()
中断:interrupt()、interrupted()、isinterrupted()
休眠:sleep()
假设有两个线程 threadA
和 threadB
。如果从 threadA
调用 threadB.join()
,threadA
将等待 threadB
完成后再继续执行。
如果调用 join
的线程在等待另一个线程完成时被中断,则 join
方法可能会引发 InterruptedException
。
thread.yield()表示线程主动放弃CPU,它用于暂时暂停当前线程的执行,并允许其他线程运行。它会被移动到等待运行的线程队列的末尾,其他线程将有机会运行。
“thread.yield()”仅在多线程环境中有效,其行为是平台相关的。不能保证在线程调用”thread.yield()”后立即运行其他线程。实际行为可能取决于操作系统使用的调度程序和其他线程在队列中的优先级。
wait():当一个线程A调用一个共享变量的 wait()方法时, 线程A会被阻塞挂起, 发生下面几种情况才会返回:
(1) 线程A调用了共享对象 notify()或者 notifyAll()方法;
(2)其他线程调用了线程A的 interrupt() 方法,线程A抛出InterruptedException异常返回。
*6. 线程有哪几种状态?
状态
说明
NEW
初始状态:线程被创建,但还没有调用start()方法
RUNNABLE
运行状态:Java线程将操作系统中的就绪和运行两种状态笼统的称作“运行”
BLOCKED
阻塞状态:表示线程阻塞于锁
WAITING
等待状态:表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING
超时等待状态:该状态不同于 WAITIND,它是可以在指定的时间自行返回的
TERMINATED
终止状态:表示当前线程已经执行完毕
7. 什么是守护线程? 线程一般分用户线程和守护线程。
在JVM启动时会调用main函数,main函数所在线程就是用户线程。但是在JVM内部其实还存在其他很多线程,比如垃圾回收线程。在JVM退出后,用户线程会退出,但是守护线程不一定。
8. 线程间有哪些通信方式?
全局变量:线程可以访问全局变量并对其进行读写操作。
管道(pipe):管道是一种特殊的文件,它允许不相关进程间的数据交换。
信号量(semaphore):信号量是一种控制多个线程同时访问共享资源的方法。
消息队列(message queue):消息队列允许线程通过发送和接收消息来通信。
共享内存(shared memory):共享内存是一段可供多个线程访问的内存。
互斥量(mutex):互斥量是一种同步机制,用于防止多个线程同时对共享资源进行访问。
条件变量(condition variable):条件变量是一种同步机制,用于控制线程的执行顺序。
这些通信方式在不同的操作系统和编程语言中可能会有所差异,请以具体环境为准。
在Java中方式如下:
9. 什么是ThreadLocal? ThreadLocal,也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。
创建了一个ThreadLocal变量localVariable,任何一个线程都能并发访问localVariable。
1 public static ThreadLocal<String> localVariable = new ThreadLocal<>();
线程可以在任何地方使用localVariable,写入变量。
1 localVariable.set("鄙人三某”);
线程在任何地方读取的都是它写入的变量。
如果修改了ThreadLocal
的值,它将在本线程内生效。每个线程都有自己的ThreadLocal
副本,并且每个线程只能修改它自己的副本。因此,当您修改了线程的ThreadLocal
值时,不会影响其他线程中的ThreadLocal
值。
例如,如果使用ThreadLocal
来存储当前用户的名称,并在多个线程中处理请求,则每个线程都有自己的副本,存储了当前处理请求的用户的名称。如果某个线程修改了它的ThreadLocal
值,则不会影响其他线程的ThreadLocal
值。
10. 使用ThreadLocal举例? 可以用来做用户信息上下文的存储。
我们的系统应用是一个典型的MVC架构,登录后的用户每次访问接口,都会在请求头中携带一个token,在控制层可以根据这个token,解析出用户的基本信息。那么问题来了,假如在服务层和持久层都要用到用户信息,比如rpc调用、更新用户获取等等,那应该怎么办呢?
一种办法是显式定义用户相关的参数,比如账号、用户名……这样一来,我们可能需要大面积地修改代码,多少有点瓜皮,那该怎么办呢?
这时候我们就可以用到ThreadLocal,在控制层拦截请求把用户信息存入ThreadLocal,这样我们在任何一个地方,都可以取出ThreadLocal中存的用户数据。
很多其它场景的cookie、session等等数据隔离也都可以通过ThreadLocal去实现。
我们常用的数据库连接池也用到了ThreadLocal:
数据库连接池的连接交给ThreadLocal进行管理,保证当前线程的操作都是同一个Connnection。
ThreadLocal 内存泄露是怎么回事?:https://tobebetterjavaer.com/sidebar/sanfene/javathread.html#_13-threadlocal-%E5%86%85%E5%AD%98%E6%B3%84%E9%9C%B2%E6%98%AF%E6%80%8E%E4%B9%88%E5%9B%9E%E4%BA%8B
11. Java中父子线程如何共享数据? 父线程能用ThreadLocal来给子线程传值吗?毫无疑问,不能。那该怎么办?
这时候可以用到另外一个类——InheritableThreadLocal
。
使用起来很简单,在主线程的InheritableThreadLocal实例设置值,在子线程中就可以拿到了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class InheritableThreadLocalTest { public static void main(String[] args) { final ThreadLocal threadLocal = new InheritableThreadLocal(); // 主线程 threadLocal.set("不擅技术"); //子线程 Thread t = new Thread() { @Override public void run() { super.run(); System.out.println("鄙人三某 ," + threadLocal.get()); } }; t.start(); } }
原理:在Thread类里还有另外一个变量:
1 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
在Thread.init的时候,如果父线程的inheritableThreadLocals
不为空,就把它赋给当前线程(子线程)的inheritableThreadLocals
。
1 2 3 if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
12. volatile关键字的作用? volatile有两个作用,保证可见性 和有序性 。
可见性:
相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。
关键字volatile可以用来修饰字段(成员变量),volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。
例如,我们声明一个 volatile 变量 volatile int x = 0,线程A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。
有序性:
重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。
*13. 简单介绍下synchronized? Synchronized是用来同步代码,使其在多线程环境下,保证原子性的。
Synchronized有三种用法,一种用于实例方法,一种用于静态代码块,一种用于静态方法。
修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
1 2 3 synchronized void method() { //业务代码 }
修饰静态方法 :也就是给当前类加锁,会作用于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。
如果⼀个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调⽤这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
1 2 3 synchronized void staic method() { //业务代码 }
修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进⼊同步代码前要获得 当前 class 的锁
1 2 3 synchronized(this) { //业务代码 }
14. Monitor是什么? Monitor是一种同步机制,在Java虚拟机(HotSpot)中,Monitor由ObjectMonitor实现的,可以叫内部锁或者Monitor锁。
15. 除了原子性,synchronized可见性,有序性,可重入性怎么实现?
在加锁前,将清空线程本地内存中共享变量的值,从而使用共享变量时需要从共享内存中读取。
synchronized的可见性是保证在一个线程访问共享内存时,不让其他线程同时访问,并且在线程修改完某个变量后,将修改后的值,同步刷回到共享内存。
synchronized的有序性是保证在一条线程访问时,禁止另一条线程进入。因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是并不会禁止底层的指令重排。
synchronized的可重入性是指允许一个线程二次请求自己持有对象锁的临界资源 ,是通过其本身的一个计数器保证的。当一条线程获取到锁时,count计数器加1,线程执行完成后则减1,直到被清零释放锁。
16. as-if-serial又是什么?单线程的程序一定是顺序的吗? as-if-serial是指:不管底层指令如何重排序,程序的执行结果不能被改变。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。如果不存在数据依赖关系,则可能被重排序。看以下示例:
1 2 3 double pi = 3.14; // A double r = 1.0; // B double area = pi * r * r; // C
在上面的示例中,C依赖于A和B,但是A和B之前不存在依赖关系。因此,执行顺序可能是A-B-C或B-A-C。
17. 什么是指令重排? 指令重排可以分三种情况:
编译器优化的重排序:在语句执行时,如果不存在依赖关系,则可以进行重排序。
内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储看上去是乱序执行。
指令并行的重排序:现代处理器采用指令并行技术,使得多条指令可以并行执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
18. 锁的状态有哪些?Synchronized锁是如何升级的? 状态有无锁、偏向锁、轻量级锁、重量级锁。升级是从 无锁->偏向锁->轻量级锁->重量级锁。
在JDK1.6以前Synchronized实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁 。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化。
Java对象头里,有一块结构,叫Mark Word
标记字段,这块结构会随着锁的状态变化而变化。
64 位虚拟机 Mark Word 是 64bit,我们来看看它的状态变化:
19. 介绍一下各种锁状态? 偏向锁: 偏向锁是在单线程环境下使用的,是防止CAS过度消耗资源。引入偏向锁是为了在多线程竞争的情况下尽量减少不必要的轻量级 锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令。(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所 以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。
Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线 程多次获得,因此有了偏向锁。
轻量级锁: 它的本意是在没有多线程竞争的前提下,减少传统的重量 级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场 景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀 为重量级锁。
轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进 一步提高性能。
重量级锁: Synchronized依赖于对象内部的Monitor锁实现。但是这个Monitor锁本质是依赖于底层操作系统的Mutex Lock实现,而线程从用户态切换到内核态开销巨大,这就是为什么重量级锁耗费资源的原因。
锁
优点
缺点
适用场景
偏向锁
加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。
如果线程间存在锁竞争,会带来额外的锁撤销的消耗。
适用于只有一个线程访问同步块场景。
轻量级锁
竞争的线程不会阻塞,而是自旋,提高了程序的响应速度。
如果始终得不到锁竞争的线程使用自旋会消耗CPU。
追求响应时间。同步块执行速度非常快。
重量级锁
线程竞争不使用自旋,不会消耗CPU。
线程阻塞,响应时间缓慢。
追求吞吐量。同步块执行速度较长。
20. 介绍一下锁的其他优化? 适应性自旋: 在轻量级锁获取不到CPU时,是会通过自旋不断获取的,然而这是比较耗费CPU的,因此做了相关优化。比如某一次自旋获取CPU成功了,那么下一次自旋次数便会增加;如果获取失败了,下一次便会减少。
锁粗化: 当连续几段代码有加锁、解锁操作时,将其合并在一起操作。例如:
1 2 3 4 5 6 7 8 public class StringBufferTest { StringBuffer stringBuffer = new StringBuffer(); public void append(){ stringBuffer.append("a"); stringBuffer.append("b"); stringBuffer.append("c"); } }
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
锁消除: 锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。
21. ConcurrentHashMap中的分段锁思想? 分段锁其实是一种思想,而ConcurrentHashMap则是这种思想的最佳实践。
与ConcurrentHashMap相似的有HashTable,但是因为效率低下而被弃用,其效率低下的主要原因就是众多线程竞争同一把锁。
而ConcurrentHashMap则允许容器中有多个锁,每把锁锁一部分数据,这样就提高了并发时的效率。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问
Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。
22. 什么是可重入锁? 可重入锁(Reentrant Lock)是一种常用的同步机制,允许同一线程多次获取锁。也就是说,如果一个线程已经获得了锁,它在释放该锁之前可以再次获取该锁。这是一种递归锁,用于保护复杂的代码块,其中一个线程可以在不释放锁的情况下进入该代码块多次。
可重入锁在多线程环境下非常有用,因为它允许线程在获得锁后再次请求锁而不会发生死锁,并且提高了代码的可读性和可维护性。
例如,在 Java 中,可重入锁通常实现为 java.util.concurrent.locks.ReentrantLock 类。
*23. 说说Synchronized和ReentrantLock的区别?
锁的实现: synchronized是Java语言的关键字,基于JVM实现。而ReentrantLock是基于JDK的API层面实现的(一般是lock()和unlock()方法配合try/finally 语句块来完成。)
性能: 在JDK1.6锁优化以前,synchronized的性能比ReenTrantLock差很多。但是JDK6开始,增加了适应性自旋、锁消除等,两者性能就差不多了。
功能特点:
ReentrantLock 比 synchronized 增加了一些高级功能,如等待可中断、可实现公平锁、可实现选择性通知。
ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制
ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
synchronized与wait()和notify()/notifyAll()方法结合实现等待/通知机制,ReentrantLock类借助Condition接口与newCondition()方法实现。
ReentrantLock需要手工声明来加锁和释放锁,一般跟finally配合释放锁。而synchronized不用手动释放锁。
24. 什么是AQS? AbstractQueuedSynchronizer 抽象同步队列,简称 AQS ,它是Java并发包的根基,并发包中的锁就是基于AQS实现的。
AQS内容较多,暂不过多关注。
25. 简单介绍下ReentrantLock ? ReentrantLock是可重入独占锁,只能有一个线程可以获取该锁,其它获取该锁的线程会被阻塞而被放入该锁的阻塞队列里面。
举例看其加锁操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Example { private int counter = 0; private ReentrantLock lock = new ReentrantLock(); public void incrementCounter() { lock.lock(); // 加锁 try { counter++; } finally { lock.unlock(); // 释放锁 } } public int getCounter() { return counter; } }
new ReentrantLock()
构造函数默认创建的是非公平锁 NonfairSync。
26. Java有哪些保证原子性的方法?如何保证多线程下i++ 结果正确?
使用原子操作类,例如AtomicInteger,实现i++原子操作
使用juc包下的ReentrantLock(),对i++加锁
使用Synchronized,对i++加锁
27. 原子操作类有哪些?
28. AtomicInteger的原理? 原理就是基于CAS的。
***29. 什么是线程死锁?如何避免? 死锁是指两个线程同时竞争同一个资源,而造成相互等待的现象。
线程死锁的四个条件:互斥条件、请求与保持条件、不可剥夺条件、环路等待条件。
互斥条件: 指一个线程对已经获取到的资源排他性使用,当一个线程持有当前资源时,直至其释放,其他线程只能等待。直到占有资源的线程释放资源。
请求与保持条件: 当一个线程持有一个资源时,又请求另一个资源,而另一个资源被占用,从而一直等待造成阻塞,当前线程也不释放资源。
不可剥夺条件: 当一个线程持有一个资源时,其他线程不可剥夺其资源,直到自身使用完成后自己释放。
环路等待条件: 一个线程集合中的线程,形成了环路等待。
避免死锁需要至少 破坏以上四个条件的其中一个:
对于互斥条件,不可以破坏,因为加锁就是为了互斥。
对于请求与保持条件,可以通过一次性申请全部资源来破坏。
对于不可剥夺条件,在请求遇到阻塞时,可以通过释放自身的资源来破坏。
对于环路等待条件,可以通过给线程分配优先级,使其有顺序的执行来破坏。
30. CountDownLatch(倒计数器)了解吗? 场景1:协调子线程结束动作:等待所有子线程运行结束
例如,我们很多人喜欢玩的王者荣耀,得等所有人都点击确认之后,才能到选英雄阶段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(5); Thread 大乔 = new Thread(countDownLatch::countDown); Thread 兰陵王 = new Thread(countDownLatch::countDown); Thread 安其拉 = new Thread(countDownLatch::countDown); Thread 哪吒 = new Thread(countDownLatch::countDown); Thread 铠 = new Thread(() -> { try { // 稍等,上个卫生间,马上到... Thread.sleep(1500); countDownLatch.countDown(); } catch (InterruptedException ignored) {} }); 大乔.start(); 兰陵王.start(); 安其拉.start(); 哪吒.start(); 铠.start(); countDownLatch.await(); System.out.println("所有玩家已经就位!"); }
new CountDownLatch(5)
用户创建初始的latch数量,各玩家通过countDownLatch.countDown()
完成状态确认,主线程通过countDownLatch.await()
等待。
场景2. 协调子线程开始动作:统一各线程动作开始的时机
王者游戏中也有类似的场景,游戏开始时,各玩家的初始状态必须一致。不能有的玩家都出完装了,有的才降生。
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 public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(1); Thread 大乔 = new Thread(() -> waitToFight(countDownLatch)); Thread 兰陵王 = new Thread(() -> waitToFight(countDownLatch)); Thread 安其拉 = new Thread(() -> waitToFight(countDownLatch)); Thread 哪吒 = new Thread(() -> waitToFight(countDownLatch)); Thread 铠 = new Thread(() -> waitToFight(countDownLatch)); 大乔.start(); 兰陵王.start(); 安其拉.start(); 哪吒.start(); 铠.start(); Thread.sleep(1000); countDownLatch.countDown(); System.out.println("敌方还有5秒达到战场,全军出击!"); } private static void waitToFight(CountDownLatch countDownLatch) { try { countDownLatch.await(); // 在此等待信号再继续 System.out.println("收到,发起进攻!"); } catch (InterruptedException e) { e.printStackTrace(); } }
在这个场景中,仍然用五个线程代表大乔、兰陵王、安其拉、哪吒和铠等五个玩家。需要注意的是,各玩家虽然都调用了start()
线程,但是它们在运行时都在等待countDownLatch
的信号,在信号未收到前,它们不会往下执行。
31. 什么是线程池? 线程池,简单说就是一个拥有很多线程的容器。合理使用线程池有三个好处:
降低资源消耗
提高响应速度
提高线程的可管理性
32. 实际使用线程池的例子? 用户注册时,防止同一时刻用户注册量过大,使用了多线程。
**33. 线程池的工作流程?
当线程池创建时,是没有线程的,任务队列通过参数的形式传入。
当ThreadPoolExecutor调用 execute() 方法添加一个任务时,线程池会做如下判断:
如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
如果队列满了,但是线程数小于最大线程数,那么还是要创建非核心线程立刻运行这个任务;
如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会根据拒绝策略来对应处理。
当一个线程完成任务后,它会从队列中取下一个任务来执行。
当一个线程闲置时,此时若线程数大于corePoolSize,那么这个线程则会被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
34. 线程池的主要参数有哪些?
corePoolSize: 核心线程数。如果线程数小于核心线程数,那么新增一个任务便会创建一个新的线程来处理。如果线程数等于核心线程数,那么新增的任务会放到任务队列中。
maximumSize: 最大线程数。如果任务队列满了还有新的线程,并且线程总数小于最大线程数,那么会创建新的线程来处理任务。
keepAliveTime: 非核心线程存活时间。
unit: 非核心线程存活时间单位
workQueue: 工作队列。当核心线程满了,队列还没满的时候,任务会先进入队列中。
threadFactory: 创建线程使用的工厂
handler: 拒绝策略
*35. 线程池拒绝策略有哪些?
AbortPolicy:直接抛出RejectedExecutionException异常。
CallerRunsPolicy:用调用者所在的线程来执行任务。
DiscardOldestPolicy:丢弃最老的任务 。
DiscardPolicy:直接丢弃任务。
下面是一个配置线程池拒绝策略的代码示例:
1 2 3 Executor executor = new ThreadPoolExecutor(10,100,60,TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadPoolExecutor.CallerRunsPolicy());
如果要实现自己的拒绝策略,则可以实现RejectedExecutionHandler
接口。
36. 线程池有哪几种工作队列?
ArrayBlockingQueue: 是一个有界阻塞队列,按FIFO排序。
LinkedBlockingQueue: 是可以设置容量的链表结构的阻塞队列,按FIFO进行排序。如果不对容量进行设置,则是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。吞吐量高于ArrayBlockingQueue,newFixedThreadPool线程池使用的是该队列。
DelayQueue: 是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排列,否则根据插入的先后顺序排列。newScheduleThreadPool线程池使用了该队列。
PriorityBlockingQueue: 具有优先级的无阻塞队列。
SynchronousQueue: 是一个不存储元素的阻塞队列,当一个线程进行插入操作时,必须有另一个线程进行移除操作,否则该插入操作将被一直阻塞。newCacheThreadPool使用了该队列。
***37. 常见的线程池有哪些? 常见的线程池有以下四种:
newFixedThreadPool:固定线程数目的线程池。
newSingleThreadPool:单线程的线程池。
newCacheThreadPool:可缓存的线程池。
newScheduleThreadPool:定时及周期执行的线程池。
**38. 四种线程池的原理和适用场景? newSingleThreadExecutor: 1 2 3 4 5 6 7 public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory)); }
线程池特点
核心线程数为1
最大线程数也为1
阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
keepAliveTime为0
工作流程:
提交任务
线程池中是否有一条线程
如果有,则进入阻塞队列,没有则执行,执行完再取下一个
newFixedThreadPool: 1 2 3 4 5 6 7 public static ExecutorService newFixedThreadExecutor(ThreadFactory threadFactory) { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory)); }
线程池特点
核心线程数和最大线程数一样,自定义
阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
keepAliveTime为0
工作流程:
提交任务
如果线程数少于核心线程,创建核心线程执行任务
如果线程数等于核心线程,把任务添加到LinkedBlockingQueue阻塞队列
如果线程执行完任务,去阻塞队列取任务,继续执行。
使用场景
FixedThreadPool 适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。
newScheduleThreadPool: 1 2 3 4 public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); }
线程池特点
最大线程数为Integer.MAX_VALUE,也有OOM的风险
阻塞队列是DelayedWorkQueue
keepAliveTime为0
scheduleAtFixedRate() :按某种速率周期执行
scheduleWithFixedDelay():在某个延迟后执行
工作机制
线程从DelayQueue中获取已到期的ScheduledFutureTask(DelayQueue.take())。到期任务是指ScheduledFutureTask的time大于等于当前时间。
线程执行这个ScheduledFutureTask。
线程修改ScheduledFutureTask的time变量为下次将要被执行的时间。
线程把这个修改time之后的ScheduledFutureTask放回DelayQueue中(DelayQueue.add())。
newCacheThreadPool: 1 2 3 4 5 6 public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), threadFactory); }
线程池特点:
核心线程数为0
最大线程数为Integer.MAX_VALUE,即无限大,可能会因为无限创建线程,导致OOM
阻塞队列是SynchronousQueue
非核心线程空闲存活时间为60秒
工作流程:
提交任务
因为没有核心线程,所以任务直接加到SynchronousQueue队列。
判断是否有空闲线程,如果有,就去取出任务执行。
如果没有空闲线程,就新建一个线程执行。
执行完任务的线程,还可以存活60秒,如果在这期间,接到任务,可以继续活下去;否则,被销毁。
适用场景
用于并发执行大量短期的小任务。
39. 线程池提交execute和submit有什么区别?
execute 用于提交不需要返回值的任务
1 2 3 4 5 6 threadsPool.execute(new Runnable() { @Override public void run () { }); }
submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值
1 2 3 4 5 6 7 8 9 10 Future<Object> future = executor.submit(harReturnValuetask); try { Object s = future.get(); } catch (InterruptedException e) { } catch (ExecutionException e) { } finally { }
40. 线程池的线程数应该怎么配置? 一般的经验,不同类型线程池的参数配置:
计算密集型一般推荐线程池不要过大,一般是CPU数 + 1,+1是因为可能存在页缺失 (就是可能存在有些数据在硬盘中需要多来一个线程将数据读入内存)。如果线程池数太大,可能会频繁的 进行线程上下文切换跟任务调度。获得当前CPU核心数代码如下:
1 Runtime.getRuntime().availableProcessors();
IO密集型:线程数适当大一点,机器的CPU核心数*2。
混合型:可以考虑根绝情况将它拆分成CPU密集型和IO密集型任务,如果执行时间相差不大,拆分可以提升吞吐量,反之没有必要。
当然,实际应用中没有固定的公式,需要结合测试和监控来进行调整。
41. 如何关闭线程池? 可以通过调用线程池的shutdown
或shutdownNow
方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。
shutdown() 将线程池状态置为shutdown,并不会立即停止 :
停止接收外部submit的任务
内部正在跑的任务和队列里等待的任务,会执行完
等到第二步完成后,才真正停止
shutdownNow() 将线程池状态置为stop。一般会立即停止,事实上不一定 :
和shutdown()一样,先停止接收外部提交的任务
忽略队列里等待的任务
尝试将正在跑的任务interrupt中断
返回未执行的任务列表
shutdown 和shutdownnow简单来说区别如下:
shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
shutdown()只是关闭了提交通道,用submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。
42. 线程池有哪些状态 RUNNING、SHUTDOWM、STOP、TIDYING、TERMINATED
线程池各个状态切换如图所示:
***43. 你能设计实现一个线程池吗?
线程池中有N个工作线程
把任务提交给线程池运行
如果线程池已满,把任务放入队列
最后当有空闲时,获取队列中任务来执行
代码参考Cubox的 “一个简单线程池的实现”
44. 单机线程池执行断电了应该怎么处理? 可以对任务队列和线程池做持久化处理,等待恢复供电后根据回溯日志做出相关处理。
45. 简单介绍下Fork/Join框架? Fork/Join框架是Java7提供的一个用于并行执行任务的框架,Fork/Join框架采用的是分而治之的思想,当有许多任务时,分割成许多小任务,将小任务放到不同的任务队列中,创建各自的线程处理这些任务。当有线程先完成任务时,它去其它线程的队列里窃取一个任务来执行,这种方式叫做工作窃取。
减少窃取任务线程和被窃取任务线程之间的竞争,通常任务会使用双端队列,被窃取任务线程永远从双端队列的头部拿,而窃取任务的线程永远从双端队列的尾部拿任务执行。