进程,线程与协程
- 进程(Process)是OS分配资源的基本单位,拥有独立的内存空间和系统资源,故进程之间切换开销较大,一个进程可以拥有多个线程。
- 线程(Thread)是CPU调度和执行的基本单位,只拥有独立的运行栈和程序计数器,共享进程的资源,线程之间切换开销小。
- 协程(Coroutine)是一种用户态的轻量级线程,由程序而不是操作系统控制,切换开销极小,可以在单线程内实现高并发。
多线程使用场景
- IO密集型任务经常需要等待IO,可以开启多个线程去执行任务以充分利用CPU,即并行。
- 例如AB两个任务执行时间都很长,且相互独立,此时可以开启两个线程并行执行AB任务,等待时间变为A或B任务的执行时长。
- 高并发场景时,服务端需要处理多个用户请求,为每个请求分配一个线程。
- Tomcat可以选择BIO或NIO,默认是BIO。
- BIO:设置线程池,收到一个请求为其分配一个线程,虽然线程的利用率高,但是依旧是阻塞的,请求数太多把线程占满了其他线程就需要排队。
- NIO:NIO使用了多路复用技术,一个线程可以监听多个连接,哪个连接数据准备好了再去读取,并发性较高。
线程的创建方式
- 继承Thread类(单继承限制)
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running");
}
}
public class ThreadExample {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 启动线程
}
}
- 实现Runable接口(无返回值)
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Thread is running");
}
}
public class RunnableExample {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start(); // 启动线程
}
}
- 实现Callable接口(可以有返回值和抛出异常)
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "Thread is running";
}
}
public class CallableExample {
public static void main(String[] args) {
MyCallable callable = new MyCallable();
// 使用 FutureTask 包装 Callable 任务
FutureTask<String> futureTask = new FutureTask<>(callable);
// 创建线程并启动
Thread thread = new Thread(futureTask);
thread.start();
try {
// 获取任务执行结果
String result = futureTask.get(); // 阻塞方法
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 使用线程池(避免频繁创建销毁线程,重复利用,便于管理,提高响应速度)
public class ThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> {
System.out.println("Task 1 is running");
});
executor.submit(() -> {
System.out.println("Task 2 is running");
});
executor.shutdown();
}
}
- 使用CompletableFuture(创建异步任务,支持链式调用和组合操作)
public class CompletableFutureExample {
public static void main(String[] args) {
CompletableFuture.runAsync(() -> {
System.out.println("Task is running");
}).join(); // 等待任务完成
}
}
为什么调用start执行run方法,而不是直接调用run方法?
- 调用start会启动一个线程并使其加入就绪状态,运行时就会调用run方法,而直接执行run方法没有创建新线程,还是在主线程下执行普通方法。
线程的生命周期
- 线程阻塞三种情况(阻塞结束之后进入就绪状态)
- 等待阻塞:执行了wait,线程进入等待队列,线程被唤醒后还需要去竞争同步锁。
- 同步阻塞:线程没有获取到锁,进入阻塞队列。
- 其他阻塞:线程执行了sleep或join或IO请求。
- 线程死亡三种情况
- 正常结束:run()或call()执行完成。
- 异常结束:抛出未捕获的异常。
- 调用stop。
多线程三大特性
- 原子性:一个操作无法再被分割,其中的子操作要么全部执行要么全部不执行。通过原子类(底层是CAS)和锁机制来保证原子性。
- 可见性:一个线程修改共享变量时,其他线程能够立即看到,通过Volatile关键字保证可见性(线程从主内存中读取变量,而不是在线程的工作内存中读取,修改变量时写回主内存)。
- 有序性:指程序执行的顺序应该按照代码中定义的先后顺序执行,但实际上处理器会对指令优化重排。通过Volatile关键字可以禁止重排序。
线程安全
引发线程安全的原因是多个线程对共享数据的非原子性操作,解决线程安全问题的方式包括乐观锁,悲观锁和ThreadLocal等。
-
Volatile关键字
- volatile关键字可以保证线程之间的有序性(禁止指令重排)和可见性,但是不保证原子性。
- volatile原理是在汇编代码中加了lock addl指令,即设置内存屏障使得后续指令不能重排到内存屏障前,并且对volatile变量的修改和读取都要去主内存中进行。
-
CAS(乐观锁)
- CAS:比较并替换,CAS是一条CPU同步原语,是原子性的,通过操作系统指令实现,依赖硬件。CAS将需要读写的内存值与预期原值比较,如果相同,认为此值在更新之前没有被其他线程先更新过,直接更新该值,否则认为此值被更新过,自旋重新获取值并比较。
- 存在问题
- ABA问题:若值先被修改为别的值后又被修改回来,此时CAS无法感知到,故会认为没有被修改过,可以使用版本号解决这个问题。
- 自旋时间长:CAS如果一直执行不成功,一直自旋会带来较大开销,需要设置最大自旋次数。
- 只能保证一个变量的原子操作(将多个变量封装成对象,使用AtomicReference保证原子。性)
- 原子类:AtomicInteger等原子类底层实现就是CAS,并加了volatile关键字,比synchronized加锁更加轻量级,效率更高。
- 自旋锁:基于CAS实现,允许线程忙等待获取锁,适用于持有锁的线程能够在短时间内释放锁的场景,此时其他等待线程不需要做内核态和用户态之间的切换,只需要自旋等待即可。
- 自旋时间不能过长否则浪费CPU。
- ReentrantLock在锁的竞争激烈程度较低且锁持有时间较短的情况下使用自旋锁。
- 自适应自旋锁:即锁的自旋次数不再固定,而是由前一次的自旋时间来决定(前一次自旋久则降低自旋次数,前一次自旋快则提高自旋次数)。
-
同步锁关键字synchronized(悲观锁)
- synchronized作用:通过加锁限制线程访问共享变量从而避免线程安全问题,可以保证原子性,可见性和有序性(本身保证原子性,加内存屏障保证可见性和有序性)。
- synchronized用法
// 修饰实例方法(锁定调用该方法的对象) public class SynchronizedExample { public synchronized void method() { // ... } } // 修饰静态方法(锁定当前类的Class对象) public class SynchronizedExample { public static synchronized void staticMethod() { // ... } } // 修饰代码块,指定加锁对象确保同一时间只有一个线程可访问(常指定为类名.class) public class SynchronizedExample { private final Object lock = new Object(); public void method() { synchronized (lock) { // 线程安全的代码 } } }- synchronized的原理
- Synchronized基于监视器对象(Monitor),每个Java对象的对象头中存在Mark Word(包括HashCode、GC分代年龄、线程持有的锁、偏向线程ID等信息),每个Java对象都关联一个Monitor对象,使用Synchronized给Java对象上锁后,Java对象的Mark Word中的持有锁信息会指向Monitor对象,线程要去访问这个Java对象,必须要把Mark Work复制到自己的栈帧中,并去取锁。
- Monitor对象包括owner(当前被哪个线程持有),wait队列(处于wait状态的线程),block队列(处于block状态的线程)等结构。
- 假设线程A进入block队列,通过CAS为owner赋值(取锁),取锁成功后其他线程就无法获取到锁,等线程A执行结束,将owner设为null(释放锁),若调用了wait方法也会释放锁,并加入wait队列,被notify唤醒后再进入block队列竞争锁。
- Synchronized是重量级锁,阻塞线程涉及用户态和内核态的切换,故比较耗时。
- synchronized锁升级
- 锁升级的过程为:无锁->偏向锁->轻量级锁->重量级锁(只升不降)。锁升级可以降低锁带来的性能消耗。
- 对象头的Mark Work中有一个threadId字段,第一次访问时threadId为空,jvm让其持有偏向锁,将threadId设置为线程id,若同一个线程再次访问,threadId一致可以直接进入,若不一致则升级为轻量级锁,通过自旋来获取锁,即CAS,自旋一定次数还没获取到锁后升级为synchronized。
- 锁在全局安全点,执行清理任务时会触发降级(主要是恢复锁对象的对象头)。
- synchronized与volatile的区别
- volatile是告诉JVM需要从主内存读取最新变量值,更新变量值后需要写回主内存。synchronized是锁定当前变量,只能由当前线程访问。
- volatile只能用于变量,synchronized可用于变量,方法和类。
- volatile保证可见性和有序性,synchronized保证可见性,有序性,原子性。
- volatile不会阻塞线程,synchronized会阻塞线程。
- synchronized与ReentrantLock(Lock)的区别
- 都是可重入锁。
- synchronized是关键字,依赖于JVM,而ReentrantLock或lock是函数,依赖于API。
- synchronized自动加锁解锁(包括发生异常时),ReentrantLock手动加锁解锁。
- synchronized可用于变量,方法,类,代码块;ReentrantLock只能用于代码块。
- ReentrantLock比synchronized多了一些高级功能:实现了公平锁,等待可中断
- 公平锁即先等待的线程先获取锁。synchronized为了保证效率,使用的是非公平锁,即新来的线程可以直接尝试获取锁,而不是严格按照阻塞队列中的线程出队获取锁。非公平锁可能导致饥饿现象,但是概率比较小。
- JVM对synchronized的优化
- 锁升级:降低锁的开销。
- 锁消除:去除不可能存在竞争的锁。
- 锁粗化:扩大锁的范围避免反复加锁和释放锁。
- 会释放锁的操作
- 同步代码块,同步方法执行结束。
- break,return等终止代码运行的操作。
- 出现了未处理的异常或错误。
- 执行了wait方法导致线程暂停并释放锁(注意sleep和yield不会释放锁)。
-
可重入锁ReentrantLock(悲观锁) public class ReentrantLockExample { private final ReentrantLock lock = new ReentrantLock(); public void method() { lock.lock(); // 加锁(阻塞,有非阻塞方法trylock) try { // 线程安全的代码 } finally { lock.unlock(); // 解锁 } } }
- 可重入锁允许线程在持有锁的情况下多次进入同步代码块而不会产生死锁,即同一个线程可以重复获取同一个锁而不会被阻塞(锁的计数器会增加),可重入锁避免了线程持有锁的情况下又调用了同一个对象的另一个同步方法导致自己被自己阻塞,形成死锁。
- ReentrantLock支持公平锁和条件变量。
- ReentrantLock主要依靠AQS实现。
- ConcurrentHashMap中的分段锁是基于ReentrantLock实现的。
-
ThreadLocal
- ThreadLocal是线程本地变量,如果创建了一个ThreadLocal变量,那么访问该变量的每个线程都会有该变量的一个本地拷贝,线程操作这个变量实际是操作自己本地内存里的变量,避免了线程安全问题。
- ThreadLocal应用场景有数据库连接池,会话管理,作为同一个线程内不同函数共享的变量。
- ThreadLocal底层使用Map实现的,每个线程都有属于自己的ThreadLocalMap,key是ThreadLocal变量本身(是弱引用),value是当前线程的ThreadLocal变量的值(是强引用),这样设计是为了让垃圾回收器及时回收无人使用的ThreadLocal变量,减少内存泄漏风险,但是另一方面,key被回收,但是value却没有被回收,此时value内存泄漏,故使用完ThreadLocal后要及时调用remove方法释放内存。
-
unsafe类
- unsafe类的操作不是java标准的操作,例如unsafe类可以做内存操作,实现CAS,内存屏障,线程调度,系统相关操作等。
- unsafe类对象无法直接new获取,可以利用反射获取unsafe类中已经实例化完成的单例对象。
- JUC中大量使用了unsafe类,例如在堆外分配内存(脱离JVM控制,且IO时不用从堆内拷贝到堆外),CAS(实现原子类,乐观锁),内存屏障(防止指令重排序导致获取锁的状态错误),阻塞线程和取消阻塞(park和unpark)。
线程通信
在同步代码块或同步方法中,可以使用以下方法进行线程间通信。
- wait:释放锁并进入wait状态。
- notify:会唤醒处于wait状态的线程,唤醒后进入阻塞状态,需要去竞争锁。
- yield:让出CPU时间片,但不会释放锁,与其他线程重新竞争时间片。
- join:子线程在主线程中启动并调用join方法,主线程阻塞,等子线程返回结果。
wait和sleep的区别
- wait和sleep都可以使线程进入阻塞状态,sleep不会释放锁而wait会。
- wait只能在同步代码块中使用,而sleep可以在任意场景使用。
- wait常用于线程间通信而sleep用于暂停执行。
- wait阻塞后需要用notify唤醒,而sleep时间到了自动苏醒。
线程池
-
线程池的七个参数
- corePoolSize:核心线程数,即使空闲依旧存活的线程数。
- maximumPoolSize:最大线程数,IO密集型任务存在线程阻塞现象时可以配置最大线程为CPU核数*2(理论,实际需要测试)。
- keepAliveTime和TimeUnit:非核心线程空闲多长时间后被释放及其时间单位。
- workQueue:阻塞队列,用来存储等待执行的任务,将超过核心线程处理能力的任务放入其中。
- threadFactory:线程工厂,可以指定线程名。
- handler:拒绝策略,包括丢弃报错,丢弃不报错,丢弃最旧的任务。
-
线程池工作流程
- 创建线程池准备好指定数量的核心线程,当到达任务数量小于核心线程将任务分配给核心线程执行,当任务数量大于核心线程时将超出的任务放入队列中。 若队列已满,但在运行的线程数量小于最大线程数,创建非核心线程运行这些任务,若运行的线程数量等于最大线程数,执行指定拒绝策略。额外创建出来的非核心线程在空闲一定时间后会被回收。
-
线程池创建
// 使用Executors工厂类创建线程池 // 固定大小线程池:保证线程数量可控,超出数量的任务会在队列中等待,但是可能在队列中缓存无限任务导致OOM。 ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5); // 单线程线程池:保证任务按顺序执行,同样需要无界队列。 ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor(); // 缓存线程池:线程数量不固定,根据需要创建新线程,空闲线程会被回收。 ExecutorService cachedThreadPool = Executors.newCachedThreadPool(); // 定时任务线程池:定期执行任务。 ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3); // 使用ThreadPoolExecutor自定义线程池 ThreadPoolExecutor customThreadPool = new ThreadPoolExecutor( 2, // 核心线程数 5, // 最大线程数 60, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new LinkedBlockingQueue<>(10), // 任务队列 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 ); // 提交任务 for (int i = 0; i < 15; i++) { customThreadPool.execute(() -> { System.out.println(Thread.currentThread().getName()); }); } // 关闭线程池 customThreadPool.shutdown(); // 不再接收新任务,但是队列中的任务需要做完 // customThreadPool.shutdownNow(); // 直接终止当前运行任务,排队的任务直接返回 // 推荐用ThreadPoolExecutor创建,可以更清晰线程池的参数,更加可控, // 使用Executors创建时若使用Fixed线程池或Single线程池,阻塞队列长度为int最大值, // 可能堆积大量请求导致OOM,使用Cache线程池可能开启大量线程导致OOM。 -
线程池阻塞队列 // 有界队列,基于数组实现 BlockingQueue queue = new ArrayBlockingQueue<>(10); // 无界队列,基于链表实现,默认容量为Integer.MAX_VALUE(可能OOM),可以指定容量 BlockingQueue queue = new LinkedBlockingQueue<>(10); // 不存储任务的队列,每个插入操作必须等待一个对应的移除操作,CacheThreadPool使用 BlockingQueue queue = new SynchronousQueue<>(); // 优先级队列 BlockingQueue queue = new PriorityBlockingQueue<>(); // 延迟队列,ScheduledThreadPool使用 BlockingQueue queue = new DelayQueue<>();
-
线程池拒绝策略 // 默认策略,直接抛出RejectedExecutionException异常(不会影响已接受的任务)。 new ThreadPoolExecutor.AbortPolicy() // 由提交任务的线程直接执行该任务 new ThreadPoolExecutor.CallerRunsPolicy() // 直接丢弃任务,不抛出异常。 new ThreadPoolExecutor.DiscardPolicy() // 丢弃队列中最旧的任务,然后尝试重新提交当前任务 new ThreadPoolExecutor.DiscardOldestPolicy() // 如果有需要,可以自定义拒绝策略
-
线程池如何复用线程
- Worker类是线程池中用于封装线程和任务的核心组件,它从任务队列中获取任务并将其交给线程执行,通过置换线程中的Runnable对象,运行其run方法起到线程复用的效果,避免频繁切换线程。
-
线程池异常处理
-
若是任务执行过程出现的异常 // 若任务通过Runnable接口提交,可以通过try-catch捕获,不会传播到主线程(出现异常的线程会被销毁)。 executor.execute(() -> { try { // 任务逻辑 } catch (Exception e) { // 处理异常 } });
// 若任务通过Callable接口提交,可以通过Future.get方法捕获。 try { future.get(); // 获取任务执行结果 } catch (ExecutionException e) { // 处理任务中的异常 System.err.println("Task failed: " + e.getCause().getMessage()); } // 自定义线程工厂可以设置异常处理器。 ... // 重写afterExecute方法可以在任务执行完成后处理异常。 ... -
由拒绝策略抛出的异常,无法直接捕获,但是可以使用自定义的拒绝策略,将其记录到日志或将任务放到其他队列中,或者使用Callable和Future,可以得知任务成功还是失败,并做对应的重试。
-
死锁
死锁指多个线程同时被阻塞,它们互相拥有对方需要的资源,又在等待对方释放资源,导致线程无限期阻塞。
- 死锁的四个必要条件
- 互斥:指资源是互斥的。
- 请求与保持:线程阻塞时不释放已有资源。
- 不剥夺:线程已持有的资源不能被其他线程抢占。
- 循环等待:多个线程之间形成循环等待关系。
- 死锁避免(破坏条件)
- 破坏请求保持:一次性申请所有资源
- 破坏不剥夺:占有部分资源的线程进一步申请资源时,若申请不到,主动释放已有资源
- 破坏循环等待:按照指定顺序申请资源,按照相反顺序释放资源。例如按照指定顺序获取锁
Fork/Join框架
Fork就是把一个任务划分出来,给多个线程去处理,然后使用join合并最后的结果。适合于那些复杂且可以划分,且多核CPU的情况。其中使用了工作窃取算法,即效率高的线程从任务队列中抢效率低的线程的任务来做,提高整体效率,一般设置一个双端队列,效率高的线程从队头抢任务,效率低的线程从队尾取任务。
- CompletableFuture异步编排
CompletableFuture<String> future = new CompletableFuture<>();
// 异步执行有返回值的任务
future.supplyAsync(() -> {
return "Hello, World!";
});
// 异步执行无返回值的任务
future.runAsync(() -> {
System.out.println("Task is running");
});
// 串联两个任务
future.supplyAsync(() -> "Hello").thenCompose(result -> CompletableFuture.supplyAsync(() -> result + ", World!"));
// thenApply:后一个线程依赖前一个线程
// thenCombine:合并两个任务
// allOf:多任务组合,等待所有任务完成
// anyOf:多任务组合,等待任意一个任务完成
// whenComplete:无论任务是否成功,都会执行的方法
AQS
AQS是一个构建锁和同步器的框架,ReentrantLock,Semaphore,SynchronousQueue都是基于AQS。
- AQS使用了一个volatile变量state来作为资源的标识,多线程访问共享资源时,若标识的共享资源空闲,则将当前获取到共享资源的线程设置为有效工作线程,共享资源设置为锁定状态(独占模式下),其他线程进入阻塞队列,等待当前线程释放资源后重新尝试获取。
- AQS底层使用同步队列+条件队列作为存储线程的容器(线程存在其中一个队列中)
- 同步队列管理获取不到锁的线程(排队或释放)(线程处于阻塞状态),入队前无锁->队列中竞争锁->离开队列持有锁。
- 条件队列管理需要满足一定条件才会被唤醒的线程(唤醒后还要去竞争锁),入队前有锁->入队时无锁->离开队列竞争锁->没竞争到加入同步队列。
- AQS独占模式,共享模式与相关组件
- 独占模式表示资源是独占的,只能被一个线程获取,如ReentrantLock。
- 共享模式表示资源有多个
- Semaphore信号量:n个资源对应n个信号量。
- CountDownLatch倒计时器:n个线程对应state为n,每当有一个线程完成任务就让state减一,直到state为0就可以唤醒所有线程了。
- AQS实现简单的独占锁
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class SimpleLock {
private final Sync sync = new Sync();
// 获取锁
public void lock() {
sync.acquire(1);
}
// 释放锁
public void unlock() {
sync.release(1);
}
// 自定义同步器
private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
// 尝试获取锁
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
@Override
protected boolean tryRelease(int arg) {
// 尝试释放锁
if (getState() == 0) {
throw new IllegalMonitorStateException();
}
setExclusiveOwnerThread(null);
setState(0);
return true;
}
@Override
protected boolean isHeldExclusively() {
// 是否独占锁
return getState() == 1;
}
}
}
JMM
- Java内存模型,它定义了一组规则解决了在多线程环境下不同线程之间共享变量的可见性、有序性、原子性问题。
- JMM是一种抽象的概念,描述了程序中各个变量的读写访问方式,JMM定义了主内存和工作内存的概念,主内存是所有线程共享的,工作内存是每个线程自己的,线程读写变量时与主内存进行交互保证变量的可见性和一致性。
- JMM定义了八种同步操作:锁定,解锁,读取,载入,使用,赋值,存储,写入。
- JMM还定义了一系列同步规则,确保在多线程环境下的正确性。