本篇为准备后端面试过程中对Java多线程&并发偏精简的总结
一、线程基础
1.1 线程的基本概念
- 线程是程序执行流的最小单元,是操作系统能够进行调度的基本单位。一个进程可以包含一个或多个线程。
- 特点:并发性、独立性、共享性
1.2 线程、进程和程序
- 程序是一组指令的集合,它是静态的,存储在磁盘上。程序本身不能执行,需要被加载到内存中才能运行。
- 进程是程序的一次执行过程,它是动态的,拥有自己的内存空间、资源和状态。一个程序可以对应多个进程。
- 线程是进程中的执行单元,它共享进程的内存空间和资源,但拥有自己的状态。一个进程可以包含多个线程。
特征 | 程序 | 进程 | 线程 |
---|---|---|---|
定义 | 指令集合 | 程序的一次执行过程 | 进程中的执行单元 |
动态性 | 静态 | 动态 | 动态 |
资源 | 无 | 独立的内存空间和资源 | 共享进程的内存空间和资源 |
状态 | 无 | 独立的状态 | 独立的状态 |
数量 | 一个程序可以对应多个进程 | 一个进程可以包含多个线程 |
1.3 java线程创建(实现)方式
- 继承Thread类
public class MyThread extends Thread {
public void run() {
System.out.println("MyThread.run()");
}
}
MyThread myThread1 = new MyThread();
myThread1.start();
- 实现Runnable接口
//如果自己的类已经 extends 另一个类,就无法直接 extends Thread,此时,可以实现一个Runnable 接口
public class MyThread extends OtherClass implements Runnable {
public void run() {
System.out.println("MyThread.run()");
}
}
//启动 MyThread,需要首先实例化一个 Thread,并传入自己的 MyThread 实例:
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
//事实上,当传入一个 Runnable target 参数给 Thread 后,Thread 的 run()方法就会调用
target.run()
public void run() {
if (target != null) {
target.run();
}
}
- 实现Callable接口
有返回值的任务必须实现 Callable
接口,类似的,无返回值的任务必须 Runnable
接口。执行Callable
任务后,可以获取一个 Future
的对象,在该对象上调用 get
就可以获取到 Callable
任务返回的 Object
了,再结合线程池接口 ExecutorService
就可以实现传说中有返回结果的多线程了。
//创建一个线程池
ExecutorService pool = Executors.newFixedThreadPool(taskSize);
// 创建多个有返回值的任务
List<Future> list = new ArrayList<Future>();
for (int i = 0; i < taskSize; i++) {
Callable c = new MyCallable(i + " ");
// 执行任务并获取 Future 对象
Future f = pool.submit(c);
list.add(f);
}
// 关闭线程池
pool.shutdown();
// 获取所有并发任务的运行结果
for (Future f : list) {
// 从 Future 对象上获取任务的返回值,并输出到控制台
System.out.println("res:" + f.get().toString());
}
- 线程池方式创建
线程和数据库连接这些资源都是非常宝贵的资源。那么每次需要的时候创建,不需要的时候销毁,是非常浪费资源的。那么我们就可以使用缓存的策略,也就是使用线程池。
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);
while(true) {
threadPool.execute(new Runnable() { // 提交多个线程任务,并执行
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " is running ..");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
1.4 线程状态
新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、等待(Waiting)、超时等待(Timed_Waiting)和终止(Terminated)。
1.5 线程控制
1. 线程调度
- yield
- yield() 是一个静态方法,用于暗示当前执行的线程愿意放弃其当前使用的处理器资源。
- 当一个线程调用 yield() 方法时,它给调度器(线程调度器)一个暗示,表明当前线程愿意让出对处理器的控制,以便调度器可以运行其他同等优先级的线程。
- 实际上,yield() 并不保证使得其他线程一定能够获得执行权,也不影响线程的状态。当前线程仍然处于可运行状态(RUNNABLE),只是给了调度器一个可以执行其他线程的机会。
- join
- join() 方法允许一个线程等待另一个线程完成之后再继续执行。
- 当在一个线程A中调用另一个线程B的 join() 方法时,线程A将进入等待状态直到线程B完成,或者达到了join() 指定的等待时间(如果设置了超时时间的话)。
- 这通常用于在开始运行依赖于其他线程执行结果的代码之前,确保这些线程已经运行完成。
- priority
- 在Java中,每个线程都有一个优先级,优先级由简单的整数表示,范围从
Thread.MIN_PRIORITY
(值为1)到Thread.MAX_PRIORITY
(值为10)。默认优先级是Thread.NORM_PRIORITY
(值为5)。 - 通过
setPriority()
方法可以改变线程的优先级。 - 调度器将尝试优先执行具有较高优先级的线程。然而,线程优先级的处理是依赖于操作系统的调度策略,并且并不是一个严格的保证。在某些操作系统上,优先级可能几乎无影响。
- 在Java中,每个线程都有一个优先级,优先级由简单的整数表示,范围从
2. 等待/通知
wait()
, notify()
和notifyAll()
是Object类的一部分,它们用于线程间的协调和通信。这些方法为等待和通知模式提供了一种机制,让一个线程暂停执行(等待状态)直到另外的线程通知它一些条件状态的变化。这些方法通常与同步代码块一起使用,来确保线程间的安全交互和避免竞态条件。
- wait()
- 当一个线程调用共享对象的wait()方法时,它会挂起自己的执行,并释放该对象上的锁,从而使其他线程可以进入同步代码块并获取该对象的锁。
- 调用wait()的线程将进入该对象的等待池,直到另一个线程在同一个对象上调用notify()或notifyAll()。
- 调用wait()时应始终在一个while循环中检查条件,以防止虚假唤醒和确保条件确实满足。
- notify()
- 当一个线程调用共享对象的notify()方法时,它会随机选择在该对象的等待池中等待的一个线程,并通知它可以继续执行。
- 这个待唤醒的线程将从等待池移动到同步队列,并等待获取对象上的锁。
- notify()并不立即释放锁;调用notify()的线程在退出同步代码块后,才会释放锁。
- notifyAll()
- 这个方法会唤醒在该对象等待池中等待的所有线程,而不仅仅是一个。
- 所有被唤醒的线程将被移动到同步队列,并开始竞争对象上的锁。只有一个线程会赢得竞争并能够继续执行;其他线程将继续在同步队列中等待获得锁。
- 类似于notify(),调用notifyAll()的线程在退出同步代码块后,才会释放锁。
3. 中断机制
- interrupt方法
- InterruptedException
二、线程池
2.1 线程池原理
线程池的工作原理是预先创建一些线程放入一个池子(也就是队列)中,这些线程都是处于休眠状态,也就是空闲状态。当有任务提交时,从池子中取出一个线程去执行这个任务,执行完该任务的线程并不会被销毁,而是再次返回到池子中等待下一次使用。
2.2 线程池优点
- 降低资源消耗。通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
- 提⾼响应速度。当任务到达时,任务可以不需要的等到线程创建就能⽴即执⾏。
- 提⾼线程的可管理性。线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。
2.3 线程池的组成
- 线程池管理器(ThreadPoolExecutor):用于创建并管理线程池,包含创建线程池,销毁线程池,添加新任务。
- 工作线程(Worker):线程池中的线程。在没有任务时,它们会处于等待状态,可以循环的执行任务。
- 任务接口(Runnable/Callable):每个任务必须实现的接口,以定义任务的入口,以供工作线程调用。
- 任务队列(BlockingQueue):用于存放待处理的任务。提供了缓存机制。
- 线程工厂(ThreadFactory):用来创建线程。
- Executor 框架:提供线程池管理的高级接口和类(如
ExecutorService
,ScheduledExecutorService
,AbstractExecutorService
)。
2.4 线程池类型
FixedThreadPool
:
创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数 nThreads 线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。
CachedThreadPool
:
创建一个可根据需要创建新线程的线程池,但是在以前构造的线程可用时将重用它们。对于执行很多短期异步任务的程序而言,这些线程池通常可提高程序性能。调用 execute 将重用以前构造的线程(如果线程可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程。因此,长时间保持空闲的线程池不会使用任何资源。
ScheduledThreadPool
:
创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。
ScheduledExecutorService scheduledThreadPool= Executors.newScheduledThreadPool(3);
scheduledThreadPool.schedule(newRunnable(){
@Override
public void run() {
System.out.println("延迟三秒");
}
}, 3, TimeUnit.SECONDS);
scheduledThreadPool.scheduleAtFixedRate(newRunnable(){
@Override
public void run() {
System.out.println("延迟 1 秒后每三秒执行一次");
}
},1,3,TimeUnit.SECONDS);
SingleThreadExecutor
:
Executors.newSingleThreadExecutor()返回一个线程池(这个线程池只有一个线程),这个线程池可以在线程死后(或发生异常时)重新启动一个线程来替代原来的线程继续执行下去!
2.5 ThreadPoolExecutor核心参数
线程池管理器,可高度定制的线程池实现。ThreadPoolExecutor核心参数:
- corePoolSize: 核心线程数,线程池创建时候,线程的数量,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到队列中。
- maximumPoolSize: 线程池最大线程数,这个参数也就是线程池能够容纳同时执行的最大线程数,超出的线程会被队列缓存。
- workQueue: 任务队列,被提交但尚未被执行的任务。
- keepAliveTime: 线程空闲时间,当线程空闲时间达到keepAliveTime后,线程会被销毁,直到只剩下corePoolSize个线程为止。
- unit: 时间单位,keepAliveTime的单位。
- threadFactory: 线程工厂,用于创建新的线程并被线程池管理。
- handler: 拒绝策略,当任务太多来不及处理时,如何拒绝任务。
2.6 ThreadPoolExecutor饱和策略
ThreadPoolExecutor的饱和策略是指当任务队列满了并且线程池中的线程数已经达到maximumPoolSize
时,采取的处理策略。ThreadPoolExecutor提供了四种饱和策略:
- AbortPolicy:这是默认的饱和策略。当任务被拒绝时,它会抛出一个未检查的RejectedExecutionException。
- CallerRunsPolicy:这种策略下,当线程池未关闭且已经饱和,那么会用调用者的线程来执行当前的任务。
- DiscardPolicy:这种策略直接丢弃任务,不给任何处理和返回。如果允许任务丢失,这是最好的一种方案。
- DiscardOldestPolicy:丢弃队列中最早的未处理任务,然后尝试再次提交当前任务。这种策略和DiscardPolicy相比,其优点在于,由于要先处理队列中后面的任务,可以使得队列中的任务对应的数据更早的得到处理。
这四种策略都是实现了RejectedExecutionHandler
接口。如果这四种策略都无法满足需求,也可以自定义RejectedExecutionHandler接口来处理饱和情况。
2.7 线程池工作过程
- 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
- 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
- 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
- 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
- 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
补充:
ThreadPoolExecutor的处理流程应该是:核心线程池已满 --> 等待队列已满 --> 线程池已满 --> ThreadPoolExecutor饱和策略。
详细的处理过程如下:
- 当一个新的任务提交给ThreadPoolExecutor时,首先会判断线程池中的线程数是否已经达到corePoolSize。如果没有达到,就创建一个新的线程来执行任务。
- 如果线程池中的线程数已经达到corePoolSize,新提交的任务就会被放到等待队列中。
- 如果等待队列已经满了,且线程池中的线程数还没有达到maximumPoolSize,就会再创建新的线程来处理任务。
- 如果线程池中的线程数已经达到maximumPoolSize,此时再有新任务提交,就会触发饱和策略。
三、并发工具类
3.1 synchronized 关键字
方法锁和对象锁,控制同步访问。
在Java中,synchronized
是一个关键字,用于实现线程间的同步,确保多个线程对共享资源的安全访问。synchronized
可以用来修饰方法或代码块,以下是关于synchronized
关键字的一些重要概念:
- 方法级别的同步:
- 在Java中,可以使用
synchronized
关键字修饰方法,以实现方法级别的同步。当一个线程进入被synchronized
修饰的方法时,会自动获取该方法所属对象的锁,其他线程则需要等待锁的释放才能进入该方法。 - 方法级别的同步适用于整个方法需要同步的情况,但可能会导致性能下降,因为其他线程在等待锁时会被阻塞。
- 代码块级别的同步:
- 除了修饰整个方法,
synchronized
还可以用于代码块级别的同步。通过synchronized
关键字后跟括号中的锁对象来指定需要同步的对象。 - 使用代码块级别的同步可以避免方法级别同步的性能问题,只对需要同步的部分代码进行同步。
- 对象锁:
- 在Java中,每个对象都有一个关联的锁,称为对象锁。当一个线程进入
synchronized
修饰的方法或代码块时,会尝试获取该对象的锁。 - 当一个线程获取了对象锁后,其他线程需要等待该锁的释放才能进入被
synchronized
修饰的方法或代码块。
- 类锁:
- 在Java中,每个类也有一个关联的锁,称为类锁。类锁是由
synchronized
修饰的静态方法或静态代码块所持有的锁。 - 类锁可以确保对于整个类的静态成员的访问是线程安全的。
- 重入性:
- Java中的
synchronized
关键字是可重入的,一个线程可以多次获取同一个对象的锁,而不会发生死锁。这种机制称为重入性。
synchronized
关键字是Java中实现同步的重要机制,可以确保多个线程对共享资源的安全访问。然而,在一些高并发情况下,synchronized
的性能可能不如其他同步机制,如Lock
接口和java.util.concurrent
包提供的并发工具类。
3.2 volatile 关键字
保证变量的可见性,避免指令重排序。
volatile
是Java中的一个关键字,用于声明变量,确保多个线程之间对变量的可见性,即当一个线程修改了被volatile
修饰的变量的值时,其他线程能够立即看到最新的值。以下是volatile
关键字的一些重要特点和用法:
- 可见性:
- 使用
volatile
修饰的变量可以保证对其他线程的可见性,即当一个线程修改了该变量的值后,其他线程能够立即看到最新的值。 - 这是因为
volatile
变量的值会被立即刷新到主内存中,并且当一个线程读取volatile
变量时,会直接从主内存中获取最新的值,而不是从线程的本地缓存中读取。
- 禁止指令重排序:
- 使用
volatile
修饰的变量会禁止指令重排序,确保了程序的执行顺序与代码的顺序一致。 - 这对于一些特定的并发场景非常重要,例如双重检查锁定模式(Double-Checked Locking)中的变量使用
volatile
修饰,可以确保线程安全地获取单例对象。
- 不保证原子性:
- 虽然
volatile
可以确保对变量的读取和写入操作的可见性,但并不保证对变量的复合操作的原子性。 - 如果一个变量的操作需要保证原子性,例如增加或减少一个计数器的值,就需要使用
synchronized
关键字或java.util.concurrent.atomic
包中的原子类来实现。
- 适用场景:
volatile
适用于状态标志位等在多线程中需要共享的变量,但不适用于需要原子性操作的场景。- 例如,当一个线程修改了一个标志位表示任务已经完成时,其他线程可以通过读取
volatile
变量来感知到这个状态的变化,从而做出相应的处理。
总的来说,volatile
关键字用于确保多个线程之间对变量的可见性,禁止指令重排序,适用于一些状态标志位等需要共享的场景,但并不保证对变量的复合操作的原子性。
3.3 Lock 接口
ReentrantLock 等具体实现提供比 synchronized 更灵活的锁操作。
Lock
接口是Java并发包中用于实现锁的一种方式,相比于synchronized
关键字,它提供了更灵活的锁定和解锁机制。以下是关于Lock
接口的一些重要特点和用法:
- 灵活性:
Lock
接口提供了比synchronized
更灵活的锁定和解锁机制。它允许使用者手动地获取和释放锁,可以在需要时选择性地获取锁、尝试获取锁、定时获取锁等。
- 条件变量:
Lock
接口提供了支持条件变量(Condition)的能力。条件变量允许线程以分组的方式等待特定的条件发生,从而更灵活地控制线程的等待和唤醒。
- 可中断性:
- 与
synchronized
关键字不同,Lock
接口提供了可中断的锁定操作。即当一个线程等待获取锁时,另一个线程可以通过中断该线程来取消等待,并抛出InterruptedException
异常。
- 公平性:
Lock
接口提供了对于锁的公平性设置。在公平模式下,锁将按照请求的顺序分配给等待线程;而在非公平模式下,锁将不保证分配给等待线程的顺序。
- 替代性:
Lock
接口提供了synchronized
关键字的替代方案,可以用于实现同步代码块和同步方法的功能。它提供了与synchronized
关键字类似的功能,但更加灵活和强大。
- 具体实现:
- Java并发包中提供了多个具体实现了
Lock
接口的类,包括ReentrantLock
、ReentrantReadWriteLock.ReadLock
、ReentrantReadWriteLock.WriteLock
等,分别用于提供可重入锁、读写锁等不同的锁定机制。
总的来说,Lock
接口是Java并发包中用于实现锁的一种方式,提供了比synchronized
更灵活和强大的锁定和解锁机制,支持条件变量、可中断性、公平性等特性。在需要更多控制和灵活性的并发编程场景中,Lock
接口是一个非常有用的工具。
3.4 Atomic 包
利用 CAS 操作实现无锁的线程安全编程(如 AtomicInteger)。
java.util.concurrent.atomic
包提供了一组原子操作类,用于在多线程环境下对变量进行原子操作,保证了操作的线程安全性。以下是关于java.util.concurrent.atomic
包的一些重要内容:
- 原子操作类:
java.util.concurrent.atomic
包提供了一系列原子操作类,包括AtomicBoolean
、AtomicInteger
、AtomicLong
等,分别用于对布尔值、整型、长整型等数据类型的原子操作。- 这些原子操作类提供了一系列的原子性操作方法,如
get()
、set()
、compareAndSet()
等,确保对变量的操作在单个方法调用中完成,并且保证了操作的原子性。
- 原子更新字段类:
- 除了基本类型的原子操作类,
java.util.concurrent.atomic
包还提供了一系列原子更新字段类,如AtomicIntegerFieldUpdater
、AtomicLongFieldUpdater
等,用于原子地更新对象的字段值。 - 这些原子更新字段类可以用于更新对象中的整型、长整型等字段的值,并提供了一系列的原子更新方法,如
compareAndSet()
、getAndIncrement()
等。
- 原子数组类:
java.util.concurrent.atomic
包还提供了一系列原子数组类,如AtomicIntegerArray
、AtomicLongArray
等,用于原子操作数组中的元素。- 这些原子数组类提供了一系列的原子操作方法,如
get()
、set()
、getAndIncrement()
等,确保对数组元素的操作在单个方法调用中完成,并且保证了操作的原子性。
- 适用性:
java.util.concurrent.atomic
包中的原子操作类适用于高并发环境下对共享变量进行原子操作的场景。它们提供了一种高效、线程安全的方式来更新变量的值,避免了使用synchronized
关键字等锁机制带来的性能开销。
- 注意事项:
- 虽然
java.util.concurrent.atomic
包提供了原子操作类,但并不是所有的情况都适合使用原子操作类。在某些需要多个操作组合而成的情况下,可能需要考虑使用锁机制来确保操作的原子性。
总的来说,java.util.concurrent.atomic
包提供了一组原子操作类,用于在多线程环境下对变量进行原子操作,保证了操作的线程安全性,并且提供了一种高效、简单的方式来实现线程安全的变量更新操作。
四、并发工具(tools)
- CountDownLatch:允许一个或多个线程等待一系列事件发生。
- CyclicBarrier:使一定数量的线程到达一个同步点后再一起继续执行。
- Semaphore:控制同时访问某个资源或操作的线程数量。
- Exchanger:两个线程在同一点交换数据的同步点。
五、并发集合(collections)
5.1 BlockingQueue
- ArrayBlockingQueue:数组结构组成的有界阻塞队列,按 FIFO(先进先出)原则对元素进行排序。
- LinkedBlockingQueue:链表结构组成的有界(但大小默认值为Integer.MAX_VALUE)阻塞队列,按 FIFO 排序元素,吞吐量通常要高于ArrayBlockingQueue。
- PriorityBlockingQueue:支持优先级排序的无界阻塞队列。
- DelayQueue:使用优先级队列实现的延迟无界阻塞队列,只有在延迟期满时才能从中提取元素。
- SynchronousQueue:不存储元素的阻塞队列,也即单个元素的队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue。
- LinkedTransferQueue:由链表结构组成的无界阻塞 TransferQueue 队列。
- LinkedBlockingDeque:由链表结构组成的双向阻塞队列。
5.2 ConcurrentHashMap
与Hashtable和synchronizedMap相比,ConcurrentHashMap具有更高的并发性。Hashtable和synchronizedMap在执行更新操作(如put和remove)时,会锁住整个Map,使得任何读/写操作都要等待锁释放,而ConcurrentHashMap则通过对Map进行分段(Segment)来实现更细粒度的锁定,这样理论上允许同时有16个线程对Map进行更新操作(实际并发度取决于更新数据的分布,以及CPU对线程的调度)。
ConcurrentHashMap使用了一种新的方法叫做锁分段技术。它使用Segment来代表这些不同的部分,每个Segment其实就是一个抽象的指向一个HashEntry数组的引用。在写操作时,只需要锁住当前段即可,这样就能保证写操作的线程安全性,同时也能保证并发性。
ConcurrentHashMap提供了线程安全的同时,读取操作大部分时候都不需要加锁,因为它只有在读到的值为null或检测到链表有环(可能是其他写线程引起的)时才需要加锁。
5.3 CopyOnWriteArrayList
六、并发编程最佳实践
- 避免共享状态:尽可能使线程不共享状态或者数据。
- 最小同步策略:尽量减小同步代码块的范围,避免死锁等问题。
- 不可变对象:使用不可变对象来避免同步问题。