☰
Java并发编程进阶:进程线程、锁机制与线程池实战解析
2026/10/17 18:45:13 网站建设 项目流程

1. 为什么把"进程线程并行并发"放在一起学

我见过太多人在多线程这条路上栽跟头,问题往往出在基础概念混淆上。比如面试被问"进程和线程的区别",张嘴就是"进程是资源分配的最小单位,线程是CPU调度的最小单位",听着像是背过书,但一问"并行和并发到底差在哪",立刻就含糊了。"JavaCore-进程-线程-并行并发-多线程"这套内容,其实就是把这些最容易被绕晕的概念,用代码和案例彻底掰扯清楚。

先明确一个定位:这不是给完全没写过Java的人看的入门读物,更适合已经能写CRUD、想往并发方向进阶的同学。你不需要背下所有API,但需要建立一套清晰的"并发脑图"——知道什么时候该用锁、什么时候该用线程池、什么时候要考虑通信。学完这套东西,回头再看那些"多线程安全""高并发优化"的帖子,基本都能看懂了。

这套知识解决的核心痛点很实在:业务系统一旦涉及定时任务、批量处理、消息推送、异步调用,就躲不开多线程。而多线程写不好,轻则数据错乱,重则死锁卡死整个服务。JavaCore里这块内容从线程的创建方式讲到锁机制,再到线程通信,正好是一条完整的"从会用到底层原理"的进阶路径。

2. 先分清几个要命的概念:进程、线程、并行、并发

2.1 进程和线程的真正区别

举一个生活化的例子:你打开微信,这是一个进程;微信里同时处理文字聊天、接收图片、播放语音消息,这些就是线程。进程是操作系统分配内存、文件句柄等资源的基本单位,线程是进程内部的执行单元,共享进程的资源。

但有个细节很多人忽略:线程共享进程的内存空间,所以线程之间通信成本低,可也正因为共享,才会出现"多线程同时改一个变量导致数据错乱"的问题。进程之间内存隔离,通信要走管道、消息队列、共享内存这类IPC机制,成本高但安全。Java里写多线程不需要直接操作进程,JVM帮我们屏蔽了操作系统进程的差异,但这不代表你不需要理解进程——遇到线上CPU飙升、内存泄漏时,你得会用系统命令看进程、看线程,才能定位问题。

2.2 并行与并发:理解这两张图的差异

简单说:并发是"看起来同时做",实际是多个任务快速地交替执行;并行是"真正同时做",需要多核CPU支持。单核CPU时代,所谓的多线程其实是并发——操作系统用时间片轮转,让每个线程轮流用CPU,因为切换速度快,用户感知不到。多核CPU出现后,并行才成为可能:两个线程分别跑在两个核心上,互不干扰。

有一个经典比喻:一个人同时吃馒头和喝粥,这是并发——一会儿咬一口馒头,一会儿喝一口粥;两个人一个吃馒头一个喝粥,这是并行。并发关注的是"任务调度和资源竞争",并行关注的是"物理上的同时执行"。Java中Runtime.getRuntime().availableProcessors()可以查看CPU核心数,如果你的多线程程序在双核机器上跑出了四倍速,那大概率是逻辑设计有bug,而不是并行带来的提升。

2.3 为什么概念不清会写出Bug

我最常看到的一个反例:有人在单核服务器上测试多线程程序,发现性能并没有提升,于是得出"多线程没用"的结论。实际上,单核CPU上多线程的主要价值不是提速,而是提高资源利用率——比如一个线程在等数据库IO时,另一个线程可以去处理CPU计算。只有当任务确实能拆分成独立片段、且运行在多个核心上时,并行才能真正缩短总耗时。

理解这些区别后,你才能做出合理的技术选型:IO密集型任务,多线程效果明显,因为线程等待IO时可以让出CPU;计算密集型任务,线程数一般设置为CPU核心数+1,多了反而因为上下文切换损耗性能。概念不通的人,一上来就开两百个线程处理计算任务,结果CPU全耗在线程切换上,程序慢得像爬。

3. Java多线程的创建方式:从继承Thread到使用线程池

3.1 三种基本创建方式回顾

Java中创建线程有四种经典方式,教科书里常写三种,但实际开发中最常用的是第四种:

  • 继承Thread类,重写run()方法
  • 实现Runnable接口,传给Thread构造器
  • 实现Callable接口,配合FutureTask获取返回值
  • 使用ExecutorService线程池

第一种方式早该淘汰了,因为Java是单继承,一旦继承Thread就再没法继承其他类,而且Thread类本身是一个"重量级"对象,直接创建线程的开销远大于复用线程池中的线程。

第二种方式是最基础的,只关注任务本身,不关注线程管理。第三种方式能拿到线程执行结果,适合需要返回值的任务,但要配合FutureTask,代码稍显啰嗦。

第四种是生产环境的标配。原因很直接:线程的创建和销毁是有成本的,假设一个任务平均执行10ms,而线程创建需要5ms,那么直接创建线程的损耗占比高达33%。线程池预期创建一定数量的线程,任务来了直接交给池中的线程执行,用完归还,避免反复创建销毁的开销。

3.2 实际写代码时用哪种

一个具体的例子说明第三种和第四种的对比。需要并发查询三个商品的库存并汇总:

// 使用Callable + FutureTask Callable<Integer> task1 = () -> queryStock("sku001"); FutureTask<Integer> ft1 = new FutureTask<>(task1); new Thread(ft1).start(); // 使用线程池 ExecutorService pool = Executors.newFixedThreadPool(3); Future<Integer> f1 = pool.submit(() -> queryStock("sku001")); Future<Integer> f2 = pool.submit(() -> queryStock("sku002")); Future<Integer> f3 = pool.submit(() -> queryStock("sku003")); int total = f1.get() + f2.get() + f3.get(); pool.shutdown();

第二种写法的好处是:线程复用,不需要手动管理线程生命周期。用new Thread()创建线程的方式在真实项目里基本看不到了,因为你无法控制线程数量,一但任务量大,内存中会堆积成千上万个线程,最终OOM。

注意:Executors.newFixedThreadPool()和newCachedThreadPool()在某些场景有隐患——前者任务队列无界,后者最大线程数为Integer.MAX_VALUE,都可能导致资源耗尽。生产环境推荐手动创建ThreadPoolExecutor,明确核心线程数、最大线程数、队列容量、拒绝策略。

3.3 创建一个规范线程池的参考配置

一个常见的配置方案:核心线程数设为CPU核心数的2倍(IO密集型)或核心数+1(计算密集型),最大线程数设为核心线程数的2倍,队列用有界ArrayBlockingQueue,拒绝策略用CallerRunsPolicy——任务被拒绝时由调用线程自己执行,避免任务无声丢失。

int core = Runtime.getRuntime().availableProcessors() * 2; ThreadPoolExecutor executor = new ThreadPoolExecutor( core, core * 2, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new ThreadFactory() { private final AtomicInteger count = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(r, "biz-thread-" + count.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() );

这里手动设置线程名非常关键。线上排查问题时,jstack输出的线程名能帮你迅速定位是哪个任务的线程卡住了。如果全部叫pool-1-thread-1,排查效率会大打折扣。

4. 线程优先级:别迷信,但有讲究

4.1 优先级本质和跨平台问题

Thread类提供了setPriority(int)方法,范围是1到10,默认是5。Java语法上允许你设置优先级,但操作系统不一定会严格按这个值调度。不同平台对优先级的映射策略不同:Windows上Java的10级映射到系统优先级后,实际效果还算明显;Linux上则可能被忽略或仅作参考。

所以,优先级只能作为"软性建议",绝不能依赖它来保证某个线程一定先执行。我在实际项目中见过有人把业务线程设为最高优先级,结果该线程疯狂抢占CPU,其他线程饿死,最终整个服务响应变慢。

4.2 什么场景值得调优先级

有一个比较实用的场景:守护线程执行一些后台清理任务,比如定期清理内存中的过期缓存,这种任务没必要跟用户请求线程抢CPU,可以把优先级调低,比如setPriority(Thread.MIN_PRIORITY)。

另外要注意,Thread.yield()和优先级是两个不同的东西。yield()只是让出当前CPU时间片,线程仍然可调度,优先级影响的是调度器在选择下一个线程时的偏好。实际开发中,我很少主动调优先级,更多是保持默认,把精力放在锁和线程池配置上。

5. 锁机制是并发编程的重头戏

5.1 synchronized 的三种用法

synchronized是Java内置锁,用法有三种:修饰实例方法、修饰静态方法、修饰代码块。

修饰实例方法时,锁对象是this;修饰静态方法时,锁对象是Class对象;修饰代码块时,可以自己指定任意对象作为锁。很多人搞混的问题是:两个线程分别调用两个不同对象上的同步实例方法,锁的是不同对象,所以不会互斥。如果你想让同一类的所有实例都互斥,应该用static synchronized。

synchronized基于对象监视器(Monitor)实现,底层与操作系统的互斥量有关,重量级锁在线程竞争激烈时会涉及用户态和内核态切换,成本较高。JDK 1.6之后引入了偏向锁、轻量级锁、重量级锁的升级过程,减少了无竞争场景下的锁开销。这时的synchronized在大多数场景下性能并不比ReentrantLock差。

5.2 Lock接口与ReentrantLock的关键方法

ReentrantLock提供了synchronized没有的灵活能力:

  • lock()与unlock()必须成对出现,一般放在try-finally中
  • tryLock()可以非阻塞地尝试获取锁,失败后返回false,避免线程无限阻塞
  • lockInterruptibly()允许在等待锁过程中响应中断
  • 支持公平锁和非公平锁,构造时传true即为公平锁

什么情况下用ReentrantLock呢?当需要"尝试获取锁,拿不到就做其他事"的时候。比如一个任务要更新用户余额,另一个任务也在更新同一用户,你可以用tryLock避免等待,直接告诉调用方"当前用户正在操作中,请稍后重试"。

另外,ReentrantLock能实现synchronized做不到的"锁的多个条件等待",这就引出了线程通信。

5.3 锁的底层状态与可见性

这里必须强调一个核心原理:锁不仅仅是互斥,它还能保证内存可见性。线程A在释放锁之前对共享变量的修改,线程B在获得同一把锁之后是可见的。否则,就算避免了多个线程同时修改数据,也可能出现"线程B读到的还是主存中的旧值"。

Java内存模型(JMM)规定,所有变量存在主内存中,线程工作内存保存变量副本。synchronized的加锁和释放会触发工作内存与主内存的同步。所以,多线程访问共享变量时,光靠volatile有时也不够,必须结合锁来保证复合操作的原子性。举个例子:

private int count = 0; public void increment() { synchronized (this) { count++; } }

count++不是一个原子操作,它包含"读取-修改-写回"三步。如果不加锁,两个线程同时读到count=5,各自加1写回,最终结果可能是6而不是7。锁的作用是让这段代码在同一时刻只有一个线程执行,从而保证了结果正确。

6. 线程通信的三种常见方式

6.1 wait/notify/notifyAll 的注意事项

线程通信最基础的方法是Object类的wait()、notify()、notifyAll(),配合synchronized使用。

关键点:wait()必须在持有锁的代码块中调用,否则抛IllegalMonitorStateException。调用wait()后,线程释放锁并进入等待队列;notify()唤醒一个等待线程,notifyAll()唤醒所有等待线程。被唤醒的线程重新参与锁竞争,获得锁后从wait()之后继续执行。

一个经典的生产者消费者场景:

synchronized (queue) { while (queue.size() == MAX_SIZE) { queue.wait(); } queue.add(item); queue.notifyAll(); }

注意这里用while而不是if来检查条件。原因是被唤醒的线程不一定能立刻重新获得锁,在等待期间其他线程可能已经把队列填满,所以需要重新检查条件。用if会导致虚假唤醒下的越界或重复消费。

6.2 Condition 实现更精确的等待通知

ReentrantLock搭配Condition可以解决wait/notify无法"指定唤醒某一类线程"的问题。一个典型的场景:队列满时生产者等待,队列空时消费者等待。如果用notifyAll,生产者唤醒的所有线程里可能包含生产者,它们醒来发现还是满的,又回去等待,白白浪费一次调度。

使用两个Condition可以做到精确通知:

ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); // 生产者 lock.lock(); try { while (queue.size() == MAX_SIZE) { notFull.await(); } queue.add(newItem); notEmpty.signal(); } finally { lock.unlock(); } // 消费者 lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } Item item = queue.remove(); notFull.signal(); } finally { lock.unlock(); }

这样一来,生产者只等待notFull,消费者只等待notEmpty,唤醒时也只唤醒对方阵营的线程,效率会高很多,代码意图也更清晰。

6.3 阻塞队列:开发中最推荐的通信方式

实际开发中,手写wait/notify或Condition的场景非常少,因为java.util.concurrent包提供了ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等阻塞队列,它们内部已经封装好了锁和等待通知逻辑。

生产者消费者只靠一个BlockingQueue就能实现:

BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100); // 生产者 queue.put(task); // 队列满时阻塞 // 消费者 Task task = queue.take(); // 队列空时阻塞

put和take方法在队列满或空时会阻塞线程,这样天然实现了流量控制。使用阻塞队列比自己手写锁加wait/notify更安全,因为减少了出错面。我们常见的线程池任务队列,底层就是BlockingQueue。

7. 常见问题与排查技巧实录

7.1 线程死锁的产生与预防

死锁的经典场景是:两个线程持有一把锁的同时,还想获得对方的锁。例如:

// 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { // 业务 } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 业务 } }

线程1持有lockA等待lockB,线程2持有lockB等待lockA,互相等待,谁也拿不到对方手里的锁,程序卡死。

排查死锁的办法:线上出问题时,用jps找到进程ID,再用jstack <pid>打印线程堆栈。如果存在死锁,jstack末尾会直接提示"Found one Java-level deadlock",并列出涉及的两个线程和锁对象。看到这个信息后,再结合代码定位。

预防死锁的思路有三条:

  • 按固定顺序获取锁,让所有线程都先拿A再拿B,就不会出现循环等待
  • 使用tryLock并设置超时,获取不到锁就释放已有锁,放弃本次操作,避免无限等待
  • 避免在持有锁时调用外部接口,因为外部接口耗时不可控,容易放大锁的持有时间

7.2 线程安全集合的使用误区

很多人知道HashMap线程不安全,就换成Hashtable,但Hashtable把所有方法都用synchronized锁住,并发度极低,读操作也要竞争同一把锁。更好的选择是ConcurrentHashMap——它采用了分段锁或CAS加锁的机制,读操作几乎不加锁,写操作只锁住特定节点,性能远高于Hashtable。

还有一个常见误区:以为用了CopyOnWriteArrayList就万无一失。这个集合读多写少的场景下很合适,因为读操作直接读原数组,写操作复制新数组再替换引用。但如果频繁写入,每次写都要复制整个数组,内存开销极大。所以"线程安全"不等于"性能好",得看具体场景。

7.3 线程池使用不当导致的任务丢失

有次我在模拟项目里遇到一个奇怪现象:某个定时任务偶尔不执行,日志里也没有异常。后来发现,用的是newFixedThreadPool(1),任务队列无界。当某个任务执行时间过长卡住时,后面的任务全部排队等候,看起来就像"丢失"了。

解决思路:给线程池的任务加上超时控制;或者用ScheduledThreadPoolExecutor配合Future.get(timeout)确保单个任务不会无限阻塞。还有,线程池拒绝策略默认是AbortPolicy,直接抛异常,如果你没捕获,任务就静默失败了。生产环境我建议用CallerRunsPolicy,至少让提交任务的线程自己兜底执行,保留任务不丢。

7.4 volatile 容易误解的地方

volatile保证了可见性,但不保证原子性。一个典型的反例:

volatile int count = 0; // 多线程执行 count++

多个线程同时读取count,各自加1再写回,仍然会发生丢失更新。因为count++是读改写三步,volatile管不了这个。要保证原子性,得用AtomicInteger、synchronized或ReentrantLock。

但volatile在"一写多读"的开关场景很常用:

volatile boolean running = true; // 线程A while (running) { // 处理 } // 线程B running = false;

没有volatile时,线程A的工作内存里可能一直缓存着running=true,线程B改了主内存的值,线程A也感知不到,导致无法退出循环。加上volatile,线程A每次读都会去主内存刷新,才能及时停止。

8. 多线程调优的实战心得

8.1 线程数配比不是拍脑袋定的

IO密集型任务和CPU密集型任务的线程数配置逻辑完全不同。IO密集型比如HTTP请求、数据库查询,线程大部分时间在等待IO,此时可以开较多的线程来充分利用CPU等待间隙。网上常见的公式是:

IO密集型:线程数 = CPU核心数 * 2 CPU密集型:线程数 = CPU核心数 + 1

但这个公式只是起点,真实环境最好用压测来验证。我在处理一个批量数据同步需求时,最初按公式配置了16个线程,但下游数据库连接池最大只有10个连接,导致线程大量等待获取连接。后来把线程数调成与数据库连接池一致,整体吞吐反而提升了。线程数不是越多越好,需要考虑下游资源的整体链路。

8.2 上下文切换是隐形杀手

为什么线程太多性能反而下降?因为每个线程在CPU上执行一段时间后,操作系统要把它的运行状态保存下来,再加载另一个线程的状态,这个保存和加载就是上下文切换。切换是需要消耗CPU时间的。

可以用thread dump分析线程状态,或用系统工具统计上下文切换次数。如果发现切换次数极高,而且线程大多是RUNNABLE但执行进展缓慢,就该考虑减少线程数,或者把任务拆分得更合理,减少无意义的竞争。

8.3 捕获多线程异常的正确姿势

线程里的异常不会像普通方法那样直接抛给调用方。如果你在run()里没有捕获,异常会传给线程的UncaughtExceptionHandler,默认处理是打印堆栈然后终止线程。如果业务要求某个任务失败后不能影响其他任务,就需要在每个任务内部自己捕获异常,或者给线程池设置ThreadFactory时指定自定义UncaughtExceptionHandler。

还有一个容易被坑的地方:ExecutorService.submit()如果返回Future,任务内部抛出的异常会被封装到Future.get()时再抛出。如果你提交任务后从不调用get(),异常就被静默吞掉了。我自己就遇到过:一个定时任务用submit提交,异常信息丢了,排查了很久才发现是数据库连接池配置错误。从那以后,凡是用submit,我一定会获取Future并调用get(),或者在任务内部把异常记录到日志。

多线程之所以难,难在问题不容易稳定复现,往往是概率性触发。但只要理解了锁、原子性、可见性、通信这几根支柱,大部分问题都能在写代码时提前规避。这套JavaCore知识越早吃透,后面剖析框架源码、排查线上故障就越是举重若轻。至少我在面对高并发场景时,靠的就是这些基础一点点把问题抠出来的。

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

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

立即咨询