Java线程与并发实践:从线程池到线程安全一次讲透
2026/9/14 1:30:28 网站建设 项目流程

做Java开发这几年,我算是把“并发”和“线程”这两个词的酸甜苦辣都尝了个遍。新项目一上,流量稍微一高,线上线程卡死、数据库连接被占满、接口响应从50ms飙到5秒的情况,十次有八次都跟线程没处理好有关。很多刚入行的同学一说Java并发,张口就是synchronized、Lock、线程池,可一旦追问背后的原理,或者让写一个线程池的合理配置,就支支吾吾了。这篇东西我打算从一个实际从业者的视角,把Java线程这条线捋清楚——从进程和线程的基础概念,到线程创建、线程安全、线程池、并发工具类,再到线上问题的排查思路,一次性讲透。无论你是准备Java面试,还是负责一个高并发的业务系统,都应该能从里面找到点能直接用的东西。

1. 线程和并发的基础认知,别让概念拖后腿

1.1 进程与线程的本质区别是什么

先补一个操作系统层面的认知。进程是操作系统分配资源的基本单位,比如我电脑上开了一个IDEA,一个微信,各自是独立的进程;线程是CPU调度的基本单位,一个进程里可以有多个线程,它们共享进程里的堆内存和方法区,各自拥有独立的程序计数器、虚拟机栈和本地方法栈。

拿餐馆来类比:进程就是一家餐馆,有厨房、桌椅、菜单这些资源;服务员就是线程,多个服务员共享同一个厨房和菜单,但每个服务员都有自己的记单本和采买路线。进程与进程之间是隔离的,跨进程通信很麻烦;线程与线程之间天然共享内存,通信方便,但同时也带来了同步问题。

这个区别直接决定了选型。如果任务之间不需要共享数据,用多进程也能做,但进程切换要保存整个地址空间,开销很大;线程切换只需要保存寄存器和栈指针,轻量得多。Java里做并发,绝大多数场景就是多线程,正是看中了这种轻量级共享内存的优势。

1.2 并发量、时间片和上下文切换

很多人把多线程理解为“同时做多件事”,严格来说这是不对的。在单核CPU上,多线程是靠时间片轮转来“假装”并行的:操作系统给每个线程分配几十毫秒的时间片,时间一到就切到下一个线程。切换时CPU要保存当前线程的寄存器状态,再载入下一个线程的状态,这个过程叫上下文切换,是有真实开销的。

上下文切换不需要背得很深,但必须有个直觉:线程不是越多越好。我的机器是8核,如果我硬开100个线程,CPU大部分时间都在做切换而非干活,系统吞吐量反而下降。面试时经常会问“并发数和线程数的关系”,其实就是在考察你是否理解这个基础。

我自己在实际项目中踩过这个坑:线上一个4核8G的实例,接口里每来一个请求就new一个线程去做计算,结果流量一打上来,CPU使用率很高但TPS上不去,最后把线程全部收拢到线程池,固定几个线程跑,性能反而翻倍。记住一句话:高并发不等于高线程数,合理控制并发数才是核心。

1.3 Java内存模型与线程安全的关系

线程安全的问题,根源在于Java内存模型(JMM)。JMM规定,所有共享变量存放在主内存中,每个线程有一个自己的工作内存,线程对变量的读写必须先操作工作内存,再同步回主内存。线程之间不能直接访问对方的工作内存,只能通过主内存来传递数据。

这就像多个服务员各自拿着一张菜单草稿,客人跟A服务员下单改了菜,B服务员手上的菜单还是旧的,除非A把信息抄回到柜台的总菜单上,B才能看到。如果不做同步,两个线程同时改一个共享变量,就会出现数据错乱。

所以并发编程要解决的就是三件事:原子性、可见性、有序性。原子性指一个操作不可分割,i++这种其实包含读、改、写三步,不是原子操作;可见性指一个线程改了共享变量,其他线程能否立即看到;有序性指编译器或CPU可能重排指令,重排后结果对单线程没有影响,但多线程下可能出问题。这三个问题环环相扣,下文讲synchronized和volatile时会一一对应。

2. 线程创建的几种方式,每种都有适合的场景

2.1 继承Thread类:适合快速demo和简单场景

最原始的方式是继承Thread类,重写run方法:

public class MyThread extends Thread { @Override public void run() { System.out.println(Thread.currentThread().getName() + " 执行中"); } }

启动时用new MyThread().start(),这里有个经典的坑:直接调用run()方法不会启动新线程,只是普通的方法调用。只有start()才会让JVM创建新线程并回调run()。

这种方式最大的问题是Java是单继承的,一个类继承了Thread,就不能再继承其他类了,而且任务和线程耦合在一起,不灵活。所以面试时你说“我会用继承Thread”,只能算及格;说“推荐用Runnable”,才算有项目经验。

2.2 实现Runnable接口:解耦任务和线程

用Runnable把任务从线程里抽出来:

public class MyRunnable implements Runnable { @Override public void run() { System.out.println(Thread.currentThread().getName() + " 执行中"); } } Thread thread = new Thread(new MyRunnable()); thread.start();

Runnable的好处是任务逻辑可以复用,也可以配合线程池使用,而且类还能继续继承别的类。缺点是run方法没有返回值,也无法抛出受检异常,如果任务执行失败,你只能在自己的run方法里catch,或者用一个结果容器来接收异常信息。

这种场景在实际业务里已经很少直接用了,一般都会走线程池,但理解Runnable仍然是基础,因为线程池内部提交的任务本质上就是Runnable或Callable。

2.3 Callable和FutureTask:拿到任务执行结果

需要返回值的任务,用Callable:

Callable<Integer> callable = () -> { Thread.sleep(1000); return 42; }; FutureTask<Integer> futureTask = new FutureTask<>(callable); new Thread(futureTask).start(); Integer result = futureTask.get();

这里的futureTask.get()会阻塞当前线程,直到任务执行完成。实际项目中很少直接用FutureTask配合new Thread,更多是把Callable提交给线程池,得到Future后再决定是阻塞等待还是轮询isDone。

要注意get()的阻塞陷阱:如果有多个任务,你逐个future.get()等结果,前面任务慢会导致后面结果也没法处理,这时候可以用CompletableFuture做异步编排,或者用CountDownLatch统一等待,后面会讲。

2.4 生产环境里到底怎么创建线程

直接说结论:生产环境里new Thread().start()基本只能出现在Demo和面试题里。线程的创建和销毁是有开销的,而且如果你不限制线程数量,高并发下线程会无限制地创建,最终导致OOM或CPU打满。真实业务中线程都是统一走线程池,由线程池管理线程的生命周期、控制并发数、复用空闲线程。

为什么面试题总爱问“创建线程有几种方式”?其实面试官想听的不是四个答案,而是你是否理解每种方式适用的场景,以及最后是否落到“生产环境用线程池”这个结论上。下一节我就细讲线程池,那才是并发编程的重头戏。

3. 线程安全怎么保证:synchronized和volatile的底层逻辑

3.1 理解原子性、可见性、有序性

先看原子性问题。i++这句代码看起来是一行,实际上编译后是三条指令:从内存读i、把i加1、把结果写回内存。两个线程同时执行,可能都读到旧值,各自加1后写回,最终结果比期望少1。解决办法是加锁,或者用AtomicInteger这类原子类。

再说可见性。一个线程给变量isRunning设成false,另一个线程在循环里读取isRunning,如果不加任何同步机制,循环线程可能永远看不到这个修改。因为工作内存里缓存了旧值,没有刷新到主内存。

最后是有序性。为了优化,编译器和CPU可能重排指令顺序,单线程下不影响最终结果,多线程下可能出现诡异现象。最典型的例子是双重检查锁的单例模式,构造方法、初始化、赋值的顺序被重排,另一个线程可能拿到一个只分配了内存但还没初始化完成的对象。

3.2 synchronized:三种写法与锁升级机制

synchronized有三种写法:

  • 修饰实例方法,锁当前对象this;
  • 修饰静态方法,锁当前类的Class对象;
  • 修饰代码块,锁任意对象。

写法不同,锁的范围不同。修饰实例方法时,如果两个线程操作同一个对象,会互斥;如果操作的是两个不同对象,就不会互斥。这个细节很容易被忽略。

很多人以为synchronized性能不好,其实是老观念。JDK 1.6之后引入了锁升级机制:偏向锁、轻量级锁、重量级锁。刚创建的对象是无锁状态,第一个线程获取锁时升级为偏向锁,记录线程ID;一旦有竞争,升级为轻量级锁,通过CAS自旋获取;自旋超过阈值,才升级为重量级锁,进入操作系统内核级的阻塞等待。

所以synchronized在低竞争场景下性能非常可观,自旋很少让线程真正挂起。这也是为什么很多现代并发框架依然大量使用synchronized的原因。只要不是高并发下的激烈竞争,synchronized并不可怕。

3.3 volatile:轻量级可见性保证

volatile是另一个面试高频词。它保证两件事:变量的可见性,以及禁止指令重排。它不保证原子性,所以volatile不能替代synchronized解决i++这类复合操作的线程安全问题。

最常见的两个场景,一是状态标志位:

volatile boolean isRunning = true; // 线程A isRunning = false; // 线程B while (isRunning) { // do something }

如果不加volatile,线程B可能永远循环下去。加了volatile,线程B每次读isRunning都会去主内存拿最新值。

另一个是双重检查锁单例:

public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

new Singleton()在JVM层面分三步:分配内存、初始化对象、把引用指向内存。编译器和CPU可能把后两步重排,导致另一个线程在instance还没初始化时就拿走了引用。加上volatile强制禁止这种重排,instance的赋值一定在对象初始化完成后发生。这是volatile非常经典的使用场景。

3.4 ReentrantLock和synchronized怎么选

ReentrantLock是java.util.concurrent包里的显式锁,和synchronized相比有几个独特的地方。第一,它可以响应中断,线程在等待锁时可以被打断,避免死等;第二,它支持超时获取锁,tryLock(3, TimeUnit.SECONDS)这种写法在业务里很实用;第三,它可以创建公平锁,让等待时间最长的线程先获得锁。

但从JDK 1.6之后synchronized经过了锁升级优化,两者的性能差距已经很小。我的原则是:能用synchronized就不用ReentrantLock,代码更简洁,也不容易出现忘记解锁的问题;需要超时控制、可中断、或者多个条件队列时,才考虑ReentrantLock。

4. 线程池:为什么它是并发编程的核心

4.1 线程池的核心参数

线程池的底层是ThreadPoolExecutor,构造时有一堆参数:

  • corePoolSize:核心线程数,即使空闲也会保留的线程数量。
  • maximumPoolSize:最大线程数,线程池能创建的最多线程数。
  • keepAliveTime:非核心线程的空闲存活时间,超过这个时间会被回收。
  • unit:keepAliveTime的时间单位。
  • workQueue:任务队列,用于存放等待执行的任务。
  • threadFactory:创建线程的工厂,决定了线程名、是否守护线程等。
  • handler:拒绝策略,当任务队列已满并且线程数达到maximumPoolSize时如何处理新任务。

这些参数环环相扣,只要有一个配置不当,线程池表现就会出问题。比如corePoolSize设得过大,低峰期也占着大量线程浪费内存;设得过小,高峰期任务大量排队,响应变慢。

4.2 阻塞队列与拒绝策略

workQueue的选择非常关键。常用的有:

  • LinkedBlockingQueue:链表实现,默认容量可以设置,是Executors.newFixedThreadPool默认使用的队列。
  • ArrayBlockingQueue:数组实现,有界队列,容量固定。
  • SynchronousQueue:不存储任务,直接提交给线程,Executors.newCachedThreadPool使用的队列。
  • PriorityBlockingQueue:优先级队列,任务按优先级执行。
  • DelayQueue:延迟队列,可以延迟一段时间后才执行任务。

拒绝策略有四种:

  • AbortPolicy:默认策略,直接抛RejectedExecutionException。
  • CallerRunsPolicy:不用线程池线程,而是让提交任务的线程自己执行任务。
  • DiscardPolicy:静默丢弃任务,不提示。
  • DiscardOldestPolicy:丢弃队列里最老的任务,再尝试提交新任务。

实际项目里我最常用的是CallerRunsPolicy,因为它能提供一种天然的背压机制:线程池忙不过来了,任务就回传给调用线程执行,调用方会变慢,从而限制请求进入的速度,而不是直接抛异常导致接口报错。

4.3 线程池的执行流程

线程池提交任务时的执行流程是:

  1. 如果当前线程数小于corePoolSize,创建新线程执行任务;
  2. 如果当前线程数大于等于corePoolSize,优先把任务放入workQueue;
  3. 如果workQueue已满,并且当前线程数小于maximumPoolSize,创建非核心线程执行任务;
  4. 如果线程数已达到maximumPoolSize且队列已满,执行拒绝策略。

这个流程面试必考,很多人在第2步和第3步的顺序上栽跟头。记住一个关键字:先排队,后加线程。意思是核心线程满了先入队,而不是立刻扩线程;只有队列满了才考虑扩到最大线程数。这个顺序决定了线程池的扩展不是激进的,而是相对保守的。

4.4 Executors工具类的坑

很多初学者喜欢用Executors工厂方法快速创建线程池,但这几个方法都不建议在生产环境直接用。newFixedThreadPool和newSingleThreadExecutor用的是无界LinkedBlockingQueue,任务堆积会撑爆内存;newCachedThreadPool最大线程数是Integer.MAX_VALUE,极端情况下会创建出海量线程;newScheduledThreadPool同样有无限线程数的风险。

所以我的习惯是永远手动new ThreadPoolExecutor,把参数、队列、拒绝策略都显式写清楚。这样不仅可控,团队review代码时也一目了然。面试里问到线程池时,主动指出Executors的坑,往往会让面试官高看一眼。

4.5 线程池参数怎么配,别再背八股文了

关于参数配置,网上的公式很多,但只靠公式会翻车。我的经验是:

  • CPU密集型任务:核心线程数可以设置为CPU核数+1。因为任务一直在算,线程多了只是增加切换成本。
  • IO密集型任务:核心线程数可以设置为CPU核数*2,甚至更大。因为任务大部分时间在等待IO,线程可以阻塞时让出CPU,多几个线程能提高吞吐量。

但真正稳妥的做法是压测。我在项目里一般先用理论值作为初始配置,然后用JMeter或自研压测脚本模拟线上流量,观察响应时间、CPU使用率、队列积压情况,再逐步调整线程数。没有人能一次配对,压测数据才是最有说服力的。

另外在实际项目中,强烈建议给线程池设置一个有意义的名字,用ThreadFactory定制:

ThreadFactory factory = new ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "order-pool-" + counter.getAndIncrement()); } };

这样排查问题时jstack导出的线程名一眼就能看出是哪个业务线程池,不用满屏thread-1、thread-2干瞪眼。

5. 高频并发工具类与死锁排查

5.1 CountDownLatch、CyclicBarrier、Semaphore

除了锁和线程池,java.util.concurrent包里还有几个非常好用的同步工具。

CountDownLatch用于“一个或多个线程等待其他线程完成”的场景。比如我批量处理1000个订单,拆成10个线程各处理100个,主线程想等所有子任务都完成后再做汇总:

CountDownLatch latch = new CountDownLatch(10); for (int i = 0; i < 10; i++) { new Thread(() -> { try { // 处理任务 } finally { latch.countDown(); } }).start(); } latch.await();

注意countDown()要放在finally里,否则任务异常退出时计数器永远减不完,主线程会一直阻塞。

CyclicBarrier和CountDownLatch有点像但语义不同,它用于一组线程互相等待,大家到齐了再一起往下走。常见场景是并行计算,所有线程把中间结果算完后站在屏障处,等人齐了再合并。CyclicBarrier的计数器是可以循环复用的,这也是它名字里Cyclic的由来。

Semaphore是信号量,控制同时访问某个资源的线程数量,本质上就是个可用的限流器:

Semaphore semaphore = new Semaphore(10); for (int i = 0; i < 100; i++) { new Thread(() -> { try { semaphore.acquire(); // 最多10个线程同时执行 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { semaphore.release(); } }).start(); }

5.2 并发集合怎么选

HashMap在多线程下直接使用是不安全的,极端情况会出现数据覆盖或链表成环。如果要求线程安全且读多写少,可以用ConcurrentHashMap,它用CAS加锁桶的方式实现高并发读写;CopyOnWriteArrayList适合读多写极少的场景,写的时候Copy一份新数组,读的时候不加锁。

队列方面,有BlockingQueue家族:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、DelayQueue、PriorityBlockingQueue等。它们除了当线程池的队列,也能用来做生产者消费者模型。LinkedBlockingQueue在有界和无界之间切换方便,ArrayBlockingQueue则因为是有界数组,更适合需要严格控制内存的场景。

5.3 ThreadLocal的适用场景和内存泄漏风险

ThreadLocal是面试里的常客,它本质上是每个线程一个副本,线程操作的是自己的副本,所以不存在竞争。它最常见的用途是传递链路信息,比如把userId、traceId放到ThreadLocal里,整个请求链路任何地方都能取到,不需要层层传参。

但ThreadLocal有两个坑要记住。第一,线程池场景下线程是复用的,ThreadLocal里的值会被下一次任务读到,造成数据串线,所以任务结束后必须显式调用remove()清理。第二,如果ThreadLocal的key是弱引用,value是强引用,在ThreadLocal对象被回收后,value还残留在线程的map里,就会造成内存泄漏。我的习惯是:用完后在finally里remove,永远不要把ThreadLocal用作全局缓存。

5.4 死锁是怎么回事,怎么快速排查

死锁是并发编程最典型的故障,它的发生需要同时满足四个条件:互斥、请求并保持、不可剥夺、循环等待。举个例子:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两个线程都在等对方释放,谁也不能继续,系统就卡死了。

Object lockA = new Object(); Object lockB = new Object(); new Thread(() -> { synchronized (lockA) { synchronized (lockB) { // do something } } }).start(); new Thread(() -> { synchronized (lockB) { synchronized (lockA) { // do something } } }).start();

排查死锁最有效的方法是拿线程dump。

  1. 先用jps -l找到Java进程ID;
  2. 再执行jstack 进程ID > dump.txt;
  3. 打开dump文件,搜索“deadlock”或“Found one Java-level deadlock”,就能看到锁等待关系。

我在线上碰到死锁时,还会用jstack多打几次,配合业务日志上的时间点。如果多个dump里同一批线程一直处于BLOCKED状态,锁的owner又没有变化,那基本就能确认是死锁了。一次dump只能看到瞬间,多次对比才能判断动态变化。

6. 实战踩坑记录:线程问题的定位和预防

6.1 线程dump里的状态都代表什么

线程dump里有几个常见状态要能看懂。RUNNABLE表示线程正在运行或等待CPU时间片;BLOCKED表示线程在等待进入synchronized代码块或方法;WAITING表示线程在调用wait、join或LockSupport.park之后无限等待;TIMED_WAITING表示有超时时间的等待,比如sleep或带超时的wait。

有一次线上接口偶发超时,我抓了dump,看到很多线程处于TIMED_WAITING状态,以为是线程卡住,后来发现是大批量线程都在sleep等待一个外部接口响应。问题不在线程,而在外部服务延迟高。所以dump只是线索,要结合代码和依赖系统一起分析。

6.2 队列积压、连接池耗尽这些典型问题

线程用完不回收,任务不断堆积,表现是线程池的队列越来越大,响应越来越慢,最后内存溢出或者拒绝策略触发。这类问题的定位思路是:先看线程池监控指标,比如活跃线程数、队列大小、拒绝次数,再回到代码里分析任务提交量是否超预期。

如果是数据库连接池被耗尽,比如HikariCP默认10个连接,但线程池最大线程数设成了50,50个线程同时去拿连接,总有40个在等待。这种时候优化方向不是再加连接数,而是减小线程池规模,或者缩短事务内耗时。很多团队一遇到连接池告警就盲目调大连接数,其实治标不治本。

6.3 我总结的几条实操经验

最后分享几个我自己在实际项目中反复用到的原则。

第一,所有线程和线程池都必须有清晰的名字和统一的命名规范,这样排查问题时jstack才能一眼定位。没有命名的线程池就是排查事故时的灾难。

第二,锁的粒度要尽量小。能用代码块锁就不用方法锁,能锁单个对象就不锁整个集合。锁的粒度越小,线程竞争的概率越低,吞吐量越高。

第三,不要在锁内做耗时的操作。比如在synchronized代码块里去调用网络请求、执行长时间的IO,这会放大锁的持有时间,导致大量线程阻塞。我的习惯是先把数据准备好,再进入临界区,快速执行完核心操作就立刻释放。

第四,慎用无界队列。生产环境建议使用有界队列,并配置合理的拒绝策略。无界队列一旦任务提交速度超过消费速度,队列会无限增长,最终把内存打爆。

第五,多线程一定要有兜底的异常处理。Runnable的run方法如果不捕获异常,线程会直接终止,但没有谁收到通知。所以每个线程的执行体里都要有try-catch,或者给线程池设置一个UncaughtExceptionHandler,确保异常不会静默丢失。

写到这里,其实Java线程的核心内容基本都覆盖了。作为Java开发者,我觉得最重要的一点是不要把并发当成一门背诵的学科,而是要在真实项目里不断压测、调优、排障。我每次线上遇到线程问题,都会把jstack日志、线程池监控、业务日志放在一起反复对比,慢慢就能形成直觉。如果你还在学习阶段,建议从一个小小的并发Demo开始,比如用线程池批量处理任务、写一个模拟死锁的案例看看dump长什么样,这些东西自己动手做一遍,比看十篇博客都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询