文章目录
一、创建线程的十种方式
继承Thread类:
这是最普通的方式,继承Thread
类,重写run
方法,如下:
public class ExtendsThread extends Thread {
@Override
public void run() {
System.out.println("1......");
}
public static void main(String[] args) {
new ExtendsThread().start();
}
}
实现Runnable接口:
这也是一种常见的方式,实现Runnable
接口并重写run
方法,如下:
public class ImplementsRunnable implements Runnable {
@Override
public void run() {
System.out.println("2......");
}
public static void main(String[] args) {
ImplementsRunnable runnable = new ImplementsRunnable();
new Thread(runnable).start();
}
}
实现Callable接口:
和上一种方式类似,只不过这种方式可以拿到线程执行完的返回值,如下
public class ImplementsCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("......");
return "123456";
}
public static void main(String[] args) throws Exception {
ImplementsCallable callable = new ImplementsCallable();
FutureTask<String> futureTask = new FutureTask<>(callable);
new Thread(futureTask).start();
System.out.println(futureTask.get());
}
}
Callable如何与Runnable联系在一起?
Runnable的一个实现类FutureTask(Future详解)再创建时能够指定传入一个给定的Callable。
可以参考这篇了解Future详解
使用ExecutorService线程池:
这种属于进阶方式,可以通过Executors创建线程池,也可以自定义线程池,如下:
public class UseExecutorService {
public static void main(String[] args) {
ExecutorService poolA = Executors.newFixedThreadPool(2);
poolA.execute(()->{
System.out.println("4A......");
});
poolA.shutdown();
// 又或者自定义线程池
ThreadPoolExecutor poolB = new ThreadPoolExecutor(2, 3, 0,
TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(3),
Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());
poolB.submit(()->{
System.out.println("4B......");
});
poolB.shutdown();
}
}
使用CompletableFuture类:
CompletableFuture是JDK1.8引入的新类,可以用来执行异步任务,如下:
public class UseCompletableFuture {
public static void main(String[] args) throws InterruptedException {
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
System.out.println("5......");
return "zhuZi";
});
// 需要阻塞,否则看不到结果
Thread.sleep(1000);
}
}
基于ThreadGroup线程组:
Java线程可以分组,可以创建多条线程作为一个组,如下:
public class UseThreadGroup {
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("groupName");
new Thread(group, ()->{
System.out.println("6-T1......");
}, "T1").start();
new Thread(group, ()->{
System.out.println("6-T2......");
}, "T2").start();
new Thread(group, ()->{
System.out.println("6-T3......");
}, "T3").start();
}
}
使用FutureTask类:
这个和之前实现Callable接口的方式差不多,只不过用匿名形式创建Callable,如下:
public class UseFutureTask {
public static void main(String[] args) {
FutureTask<String> futureTask = new FutureTask<>(() -> {
System.out.println("7......");
return "zhuZi";
});
new Thread(futureTask).start();
}
}
使用ForkJoin线程池或Stream并行流:
ForkJoin是JDK1.7引入的新线程池,基于分治思想实现。而后续JDK1.8的parallelStream并行流,默认就基于ForkJoin实现,如下:
public class UseForkJoinPool {
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool();
forkJoinPool.execute(()->{
System.out.println("10A......");
});
List<String> list = Arrays.asList("10B......");
list.parallelStream().forEach(System.out::println);
}
}
二、说说线程的生命周期和状态?
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用 start() 。
- RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
- BLOCKED: 阻塞状态,需要等待锁释放。
- WAITING: 等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING: 超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED: 终止状态,表示该线程已经运行完毕。
三、JMM(Java 内存模型)
具体参考JMM详解
四、volatile 关键字
4.1 如何保证变量的可见性?
在 Java 中,volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。 volatile
底层实现主要是通过lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存
IA-32和Intel 64架构软件开发者手册对lock指令的解释:
- 会将当前处理器缓存行的数据立即写回到系统内存.
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)
- 提供内存屏障功能,使lock前后指令不能重排序
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。
4.2 如何禁止指令重排序?
在 Java 中,volatile
关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序即保证了变量的有序性。 如果我们将变量声明为 volatile
,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
指令重排序:在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令重排序优化
重排序会遵循as-if-serial与happens-before原则
在 Java 中,Unsafe
类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:
public native void loadFence();
public native void storeFence();
public native void fullFence();
理论上来说,你通过这个三个方法也可以实现和volatile
禁止重排序一样的效果,只是会麻烦一些。
下面演示一下基于volatile
关键字实现双重校验锁实现对象单例(线程安全):
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance()
后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
4.3 volatile 可以保证原子性么?
volatile
关键字能保证变量的可见性,但不能保证对变量的操作是原子性的,保证原子性需要借助synchronized这样的锁机制
我们通过下面的代码即可证明:
public class VolatileAtomicityDemo {
public volatile static int k = 0;
public void increase() {
k++;
}
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 200; j++) {
volatileAtomicityDemo.increase();
}
});
}
// 等待2秒,保证上面程序执行完成
Thread.sleep(2000);
System.out.println(c);
threadPool.shutdown();
}
}
正常情况下,运行上面的代码理应输出 1000。但是实际输出是小于1000的,因为实际上,k++ 其实是一个复合操作不是原子的,分为三步:
- 读取 k 的值。
- 对 k 加 1。
- 将 k 的值写回内存。
volatile
是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:线程 1 对 k 进行读取操作之后,还未对其进行修改。线程 2 又读取了 k 的值并对其进行修改(+1),再将 k 的值写回内存。线程 2 操作完毕后,线程 1 对 k 的值进行修改(+1),再将 k 的值写回内存。这也就导致两个线程分别对 k 进行了一次自增操作后,k 实际上只增加了 1。其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized
、Lock
或者AtomicInteger
都可以。
五、各种锁的概念以及实现
具体内容可以参考锁详解
六、常用并发工具类
具体内容可以参考并发工具类
七、ThreadLocal
7.1 ThreadLocal 的作用
ThreadLocal
叫做线程变量,意思是ThreadLocal
中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal
为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
ThreadLoal
变量,线程局部变量,同一个 ThreadLocal
所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
- 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是
ThreadLocal
命名的由来。 - 既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
ThreadLocal
提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal
变量通常被private static
修饰。当一个线程结束时,它所使用的所有 ThreadLocal
相对的实例副本都可被回收。
总的来说,ThreadLocal
适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景
7.2 ThreadLocal的使用方法
实现一个解析jwt令牌通过ThreadLocal
传递数据到service层中的简单案例:
- 将
TreadLocal
封装成工具类方便使用
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}
- 在统一拦截器校验令牌解析出员工id时将员工id加入到
ThreadLocal
中
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
//2、校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
BaseContext.setCurrentId(empId);
log.info("当前员工id:", empId);
//3、通过,放行
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
- 在service中获取员工id
// 获取当前记录的创建人和修改人
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
7.3 ThreadLocal 原理
最终的变量是放在了当前线程的 ThreadLocalMap
中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap
的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal为 key
,Object
对象为 value
的键值对。
比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread
内部仅有一个ThreadLocalMap 存放数据,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal对象调用set方法设置的值。
ThreadLocal 数据结构如下图所示:
7.4 ThreadLocal 内存泄露问题
ThreadLocalMap
中使用的 key
为 ThreadLocal
的弱引用,而 value
是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时,key
会被清理掉,而 value
不会被清理掉。因此ThreadLocalMap
中就会出现 key
为 null 的 Entry
。ThreadLocal
的**内存泄漏主要发生在线程池中,因为 每个线程里面ThreadLocalMap的生命周期和每个线程的生命周期是一样长的,当thread对象被线程池回收过后就意味着ThreadLocalMap不会被回收(GC) .ThreadLocalMap
实现中已经考虑了这种情况,在调用 set() 、get()、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后最好手动调用remove()方法
弱引用介绍: 如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。线程池
八、线程池
具体内容可参考这篇线程池详解
常见问题
线程的生命周期
start和run方法的区别
run() 方法是定义线程主体逻辑的普通方法,当直接调用时,它在当前线程的上下文中执行,而不会创建新的线程。
start()方法是启动一个新线程的方法,当调用时,它会创建一个新的线程,并在新线程的上下文中执行 run() 方法的内容,实现多线程并发执行。 直接调用
run() 方法不会创建新线程,只是在当前线程中按顺序执行 run() 方法的内容,不具备多线程的特性。
sleep和wait区别
- wait()方法和sleep()方法都让线程暂停执行,但wait()方法会释放锁,sleep()方法不会释放锁。
- wait()方法需要被notify()或notifyAll()方法唤醒,sleep()方法会自动苏醒。
- wait()方法通常用于线程间通信和协作,sleep()方法通常用于让线程暂停执行一段时间。
如何保证多线程运行安全
使用同步机制,锁,原子变量,不可用变量,使用线程安全的集合,使用volatile
如何正确关闭线程以及线程池
关闭线程
- volatile关键字
使用自定义的标志位决定线程的执行情况
具体思路大致如下:设置一个 父线程 的状态变量,以其影响其子线程即可- intrrrupt()方法
不能终止一个正在执行着的线程,它只是修改中断标志而已
这个方法分为两种情况:
线程处于阻塞:立马退出阻塞,抛出InterruptedException异常。通过捕获这个异常,来让线程退出
线程处于非阻塞:处于运行状态不受影响,仅仅标记了线程的中断为true。在适当的位置中调用isInterrupted方法查看是否被中断并且退出
关闭线程池
优雅的关闭线程池:(比如ThreadPoolExecutor类)可以通过shutdown方法逐步关闭池中的线程(温和安全)
- shutdown():
拒收新任务,不会立即终止线程池。而是要等所有任务缓存队列中的任务都执行完后才终止。- shutdownNow():
拒收新任务,立即终止线程池。并尝试打断正在执行的任务。
并且清空任务缓存队列,返回尚未执行的任务
线程池worker作用
线程池中的线程,都会被封装成一个Worker类对象,ThreadPoolExecutor维护的其实就是一组Worker对象,其中用集合workers存储这些Worker对象;
Worker类中有两个属性,一个是firstTask
,用来保存传入线程池中的任务,一个是thread
,是在构造Worker对象的时候,利用ThreadFactory来创建的线程,用来处理任务的线程;
Worker继承AQS,使用AQS实现独占锁,并且是不可重入的,构造Worker对象的时候,会把锁资源状态设置成-1,因为新增的线程,还没有处理过任务,是不允许被中断的
- 线程生命周期管理:Worker 包装了一个 Thread 对象,它不仅仅代表线程,还负责控制线程的状态。通过 Worker 对象,线程池可以追踪线程的使用情况,确保线程可以被正确回收和重用。
- 任务执行控制:Worker 包含一个 firstTask 字段,用来存放该 Worker 第一次执行的任务。这样,线程池在创建 Worker 的时候可以立即分配任务,而不需要等待将其添加到任务队列。这种设计减少了线程池在任务处理上的延迟。
- 加锁控制:Worker 实现了 Runnable 接口,并包含了对锁的管理(继承AQS)。通过锁控制,Worker 可以在任务执行过程中防止多个线程同时操作共享资源,从而提高线程池的线程安全性。
- 方便管理与统计:Worker 包装后,线程池可以更方便地管理、统计和监控线程的使用情况,比如统计完成的任务数、正在运行的任务等,这些信息有助于提高线程池的调试和监控能力。
worker继承AQS的作用
Worker继承自AQS实际是要使用其锁的能力,这个锁主要是用来控制调用shutdown()时不要中断正在执行任务的线程。
那么为什么Worker使用AQS实现锁,而不直接用ReentrantLock呢? 我们可以看到Worker的tryAcquire
方法,它是不允许重入的,而 ReentrantLock是允许重入的。所以这是为了实现不可重入的特性去反应线程现在的执行状态。
yeid有什么用
yield方法是Thread类的一个静态方法,它的作用是暂停当前正在执行的线程,并允许其他线程执行。yield方法可以使当前运行的线程回到可运行状态,这样具有相同优先级的其他线程就有机会获得CPU执行时间。然而,yield并不保证其他线程一定会获得执行机会,因为线程调度器可能会再次选择原来的线程继续执行。
join作用
join()是 Thread
类中的一个方法,当我们需要让线程按照自己指定的顺序执行的时候,就可以利用这个方法。Thread.join()
方法表示调用此方法的线程被阻塞,仅当该方法完成以后,才能继续运行。
死锁与活锁的区别,死锁与饥饿的区别
死锁:是指两个或者两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,他们都将无法推进下去
产生死锁的原因: 互相争夺共享资源
产生死锁的必要条件:
- 互斥条件:共享资源被一个线程占用
- 请求与保持条件(占有且等待):一个进程因请求资源而阻塞时,对已获得的资源保持不释放
- 不剥夺条件:进程已获得资源,在未使用完之前,不能强行剥夺
- 循环等待条件:多个线程之前循环等待资源,必须是循环的互相等待
只需要破坏上面4个必要条件的其中一个就能破坏,比如:
- 请求与保持条件:放大锁范围,去除对资源的抢占
- 不剥夺:换成可重入锁ReentrantLock
- 循环等待:改成顺序加锁,避免循环等待
互斥是多线程的特性,所以这个条件无法避免
活锁: 任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试、失败、尝试、失败。在这期间线程状态会不停的改变
活锁与死锁的区别: 死锁会阻塞,一直等待对方释放资源,一直处在阻塞状态;活锁会不停的改变线程状态尝试获得资源。活锁有可能自行解开,死锁则不行
饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。一直有线程级别高的暂用资源,线程低的一直处在饥饿状态。比如ReentrantLock显示锁里提供的不公平锁机制,不公平锁能够提高吞吐量但不可避免的会造成某些线程的饥饿
死锁与饥饿的区别: 线程处于饥饿是因为不断有优先级高的线程占用资源,当不再有高优先级的线程争抢资源时,饥饿状态将会自动解除。
产生饥饿的原因:
- 高优先级线程抢占资源
- 线程在等待一个本身也处于永久等待完成的对象
- 线程被永久阻塞在一个等待进入同步快的状态,因为其他线程总是能在它之前持续地对该同步块进行访问
如何诊断死锁
- jstack 进程号 首先使用jps查看Java进程编号,然后使用jstack查看进程信息,出现下述信息表示出现了死锁。jstack会在最后给出进程的分析信息,表示出现了死锁。
- jconsole可视化工具
- VisualVM:故障处理工具