资讯动态

Java并发编程基础全解:多线程、锁机制与线程池实践

发布时间:2026/9/10 7:34:13 来源:尧图企业网站定制
很多初学 Java 的人拿到《Java 程序设计》这门课最容易卡住的就是并发编程这一章。你说语法吧都认识你说思路吧好像也明白真到自己写多线程代码不是数据错乱就是死锁跑起来完全不是那么回事。这篇文章我就把这门课里“并发编程基础”这部分彻底掰开揉碎从线程怎么创建、锁到底锁的是什么到线程池参数怎么定、线上问题怎么排查一次性讲透。不管你是正在上课的学生还是准备 Java 面试的求职者这篇文章都值得你认真看完。先说清楚这篇博文要解决什么问题第一帮你建立并发编程的完整知识框架第二把 synchronized、volatile、Lock、线程池这些核心知识点的底层原理讲明白第三结合我实际写代码踩过的坑告诉你哪些地方容易出问题、怎么排查。全文不整虚的全是能直接用在项目和面试里的东西。1. 并发编程到底在解决什么问题1.1 并发编程的本质是“压榨资源”很多学生学并发编程时第一个疑问是我单线程写得好好的为什么要搞多线程这个问题问得特别好因为如果你不清楚并发编程要解决什么问题后面学再多 API 都是空中楼阁。大家看现在的主流服务器动辄几十核 CPU、几百 GB 内存。你写一个程序只用单线程跑就意味着同一时间只有一个 CPU 核在工作其他核全部空转。这就好比你雇了十个小工搬砖结果你只让一个人干活剩下九个人在旁边站着看。多线程的核心目的就是让多个 CPU 核同时干活把硬件资源真正用起来。但资源压榨是有代价的。当多个线程同时访问同一份数据时就会出现竞争条件。比如两个线程同时往同一个账户里存钱如果没有同步控制最终余额可能只加了一次钱。这就是并发编程最核心的矛盾为了性能引入多线程同时又因为多线程引入数据安全问题。所有并发编程的知识点本质上都是在解决“性能”和“安全”这对矛盾。1.2 Java 并发编程的知识地图我在带新人或者给学生讲这部分内容时习惯先给一张知识地图不然大家很容易迷失在细节里。Java 并发编程基础部分其实就四块内容线程的创建与管理、线程同步与互斥、线程间协作、并发容器与工具类。线程的创建与管理解决的是“怎么把任务拆给多个线程”的问题线程同步与互斥解决的是“多个线程同时改数据怎么保证正确”的问题线程间协作解决的是“线程之间怎么互相通知、怎么配合完成复杂任务”的问题并发容器与工具类是 JDK 帮我们封装好的一些线程安全的现成组件。把这四块内容对应到《Java 程序设计》这门课上前两章一般是线程基础和 synchronized中间讲 Lock 和 volatile后面讲线程池和并发工具类。你可以对照自己的教材看看不管章节顺序怎么排核心内容跑不出这个框架。1.3 为什么说并发编程是区分程序员水平的分水岭面试时有个特别有意思的现象问 Java 基础语法几乎人人都会问到多线程一半人开始含糊再往深了问 volatile 的内存语义、线程池的拒绝策略、AQS 的实现原理能答上来的人就很少了。这倒不是因为并发编程有多高深而是因为它涉及的知识点特别杂而且每一个知识点背后都牵扯到操作系统、JVM 内存模型、CPU 缓存这些底层的概念。你光背 API 是不行的必须理解数据在 CPU、内存、磁盘之间是怎么流转的才可能真正写好并发代码。我经常跟学生说并发编程学得好不好不看你背了多少八股文就看你写出来的代码在高并发下跑不跑得稳。这篇文章后面讲的每一个知识点我都会结合真实的场景来解释而不是只给你概念定义。2. 线程的基础创建、生命周期与状态切换2.1 线程到底是怎么创建出来的Java 里创建线程有好几种方式但归根结底所有线程都是java.lang.Thread类的实例。初学者最容易困惑的是网上资料一会儿说继承 Thread一会儿说实现 Runnable一会儿又说用 Callable到底该用哪个我直接给结论在实际开发中几乎不用继承 Thread 的方式因为 Java 是单继承你继承了 Thread 就没法继承其他类了。最常用的是实现 Runnable 接口这样你的任务逻辑和线程本身是解耦的。如果需要返回执行结果就实现 Callable 接口配合 FutureTask 使用。给你看一个最简单的例子// 方式一实现 Runnable 接口 public class DownloadTask implements Runnable { Override public void run() { System.out.println(Thread.currentThread().getName() 开始下载任务); // 模拟耗时操作 try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() 下载完成); } } // 使用 Thread t1 new Thread(new DownloadTask(), download-thread-1); t1.start();注意调用的是start()方法不是run()方法。这两个方法的区别是面试高频题start()会创建一个新的线程并执行run()方法里的逻辑如果直接调用run()它只是在当前线程里执行一个普通方法根本没有创建新线程。2.2 线程的生命周期不仅仅是五态教科书上告诉你线程有新建、就绪、运行、阻塞、死亡五种状态这个没错但 Java 层面的线程状态和操作系统层面的线程状态是有映射关系的。Java 的Thread.State枚举类定义了六种状态NEW新建、RUNNABLE可运行、BLOCKED阻塞、WAITING等待、TIMED_WAITING超时等待、TERMINATED终止。这里有个特别容易误解的地方Java 的 RUNNABLE 状态其实涵盖了操作系统里的“就绪”和“运行”两个状态。因为 Java 线程的调度是由操作系统负责的Java 虚拟机本身没法精确区分线程到底是正在 CPU 上跑还是在等待被调度所以干脆统一归为 RUNNABLE。你可以用jstack命令查看运行中 Java 进程的线程状态这在我们排查线上问题时会用到。比如看到大量线程处于 BLOCKED 状态那大概率是锁竞争太激烈了看到大量 TIMED_WAITING可能是线程池里的空闲线程在等待新任务。2.3 线程中断是个协作机制很多初学者以为Thread.stop()能强制终止线程大错特错。stop()方法已经被废弃了因为它会直接释放锁导致数据不一致。正确的做法是使用中断机制。中断机制本质上是一种协作机制线程 A 调用线程 B 的interrupt()方法并不是直接打断 B 的执行而是给 B 打一个“中断标记”。B 需要在合适的时机自己检查这个标记然后决定怎么处理。public class InterruptExample { public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (!Thread.currentThread().isInterrupted()) { // 模拟执行任务 System.out.println(worker 正在工作...); } System.out.println(worker 收到中断信号停止工作); }); worker.start(); Thread.sleep(100); worker.interrupt(); } }这个机制的好处是线程可以在自己认为安全的时机停止避免强制终止导致的资源泄漏、数据不一致等问题。我在实际项目中处理任务的优雅关闭时基本都是靠这个机制让线程处理完当前任务后再退出。3. 锁的核心synchronized 和 volatile 的内存语义3.1 synchronized 锁的到底是什么要说并发编程里最重要的关键字synchronized 当之无愧。但“锁的是什么”这个问题很多工作两三年的开发都说不清楚。synchronized 有三种用法锁的目标各不相同第一种修饰实例方法锁的是当前实例对象。也就是说同一个实例的多个线程访问这个方法需要竞争锁不同实例之间互不干扰。第二种修饰静态方法锁的是当前类的 Class 对象。注意Class 对象在 JVM 中是全局唯一的所以静态方法锁是全局锁不同实例之间也会互相竞争。第三种修饰代码块可以指定任意对象作为锁。// 锁的是当前实例 public synchronized void methodA() { // 临界区 } // 锁的是类的 Class 对象 public static synchronized void methodB() { // 临界区 } // 锁的是指定对象 public void methodC() { synchronized (lockObject) { // 临界区 } }很多人问锁对象和锁代码块有什么区别锁对象只是承载锁状态的一个引用真正关键的是临界区。同一把锁保护的临界区必须互斥执行不同锁保护的临界区可以并行执行。3.2 synchronized 的底层原理从 monitorenter 说起javap 反编译一个包含 synchronized 代码块的类你会看到字节码里有monitorenter和monitorexit两条指令。每个 Java 对象在内存中都关联着一个 monitor监视器对象线程进入临界区时要先获取 monitor 的所有权退出时释放所有权。如果两个线程同时尝试进入同一个 monitor 保护的临界区只有一个能成功另一个会被阻塞直到锁被释放。在 JDK 6 之后synchronized 的性能大幅提升原因是引入了锁升级机制无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁就是锁住之后没有其他线程竞争后面同一个线程再进入就无需额外同步开销轻量级锁是在短时间竞争时通过 CAS 自旋的方式来避免线程阻塞只有竞争激烈时才会膨胀为重量级锁线程真正被阻塞挂起。3.3 volatile 不保证原子性但保证可见性volatile 是 Java 并发编程里另一个核心关键字也是面试里最容易踩坑的地方。volatile 有两个语义一是保证可见性二是禁止指令重排序。但它不保证原子性。什么叫可见性如果不加 volatile多个线程同时读一个变量某个线程修改了变量值之后其他线程可能看不到修改后的值。这是因为每个线程都有自己的工作内存变量副本可能没有及时同步到主内存。加了 volatile 之后对这个变量的读写都会直接操作主内存所有线程看到的永远是最新的值。但 volatile 不保证原子性最经典的例子就是count操作即使 count 被 volatile 修饰多个线程同时执行 count 仍然会丢数据。因为 count 在字节码层面是“读-改-写”三步操作volatile 只保证了读取和写入的直接性没法把这“三步”变成一个不可分割的整体。所以 volatile 的使用场景一般有两个一个是修饰状态标志位比如线程的停止标记另一个是在双重检查锁单例模式中修饰单例对象防止指令重排序导致返回了未完全初始化的对象。4. 显式锁与协作工具Lock、CountDownLatch、Future4.1 Lock 接口为什么比 synchronized 更灵活synchronized 用起来简单但它也有局限性不能中断等待锁的线程、不能设置超时时间、获取锁和释放锁必须写在一个方法里没有死锁才怪了其实是写法上比较容易出错。java.util.concurrent.locks.Lock接口提供了更多控制能力它的经典实现是ReentrantLock。ReentrantLock 和 synchronized 一样是可重入锁也就是同一个线程可以多次获取同一把锁而不会死锁。Lock lock new ReentrantLock(); public void doSomething() { lock.lock(); try { // 临界区逻辑 } finally { lock.unlock(); } }注意ReentrantLock 的使用必须手动加锁和解锁而且解锁一定要放在 finally 块里否则一旦临界区抛异常锁永远不会被释放其他线程就会一直阻塞。ReentrantLock 比 synchronized 多了一个非常实用的能力可以尝试获取锁获取不到就立即返回或者等待一段时间后返回。if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 获取锁成功 } finally { lock.unlock(); } } else { // 获取锁失败处理其他逻辑 }4.2 多线程都完成了怎么通知CountDownLatch 的正确用法最近热搜里有个词叫“java线程等待都完成”其实对应的正是 CountDownLatch 这个工具类。场景非常典型主线程启动了若干个工作线程需要等所有工作线程都完成之后主线程再继续往下走。CountDownLatch 的用法非常直观初始化时指定一个计数器值每个工作线程完成后调用一次countDown()方法计数器减一主线程调用await()方法计数器不为零时一直等待。int workerCount 5; CountDownLatch latch new CountDownLatch(workerCount); for (int i 0; i workerCount; i) { new Thread(() - { try { // 执行任务 System.out.println(Thread.currentThread().getName() 执行完成); } finally { // 这里也很重要即使任务抛异常也要保证计数器减一 latch.countDown(); } }, worker- i).start(); } // 主线程等待所有任务完成 latch.await(); System.out.println(所有任务都完成了主线程继续执行);这里有个细节一定要记住countDown()要放在 finally 里否则线程执行时抛异常计数器一直不减主线程就会永久阻塞。我自己就在线上遇到过这种问题排查了好久才发现是某个任务抛了异常导致 latch 没有减到底。4.3 Future 和 FutureTask获取线程执行结果Runnable 接口的 run() 方法没有返回值如果需要获取执行结果就得用 Callable 接口和 FutureTask。CallableInteger callable () - { Thread.sleep(2000); return 42; }; FutureTaskInteger futureTask new FutureTask(callable); new Thread(futureTask).start(); // 主线程可以干别的事然后再获取结果 Integer result futureTask.get(); // 阻塞等待结果 System.out.println(计算结果 result);get()方法是阻塞的如果任务还没执行完成调用 get() 的线程会一直等待。get()方法还能传入超时时间比如futureTask.get(3, TimeUnit.SECONDS)超过 3 秒还没拿到结果就抛出 TimeoutException。这个在调第三方接口时特别有用可以避免主线程无限制等待。5. 线程池不要 new Thread用线程池5.1 为什么禁止手动 new Thread很多初学者写多线程代码喜欢直接 new Thread这个习惯在生产环境必须改掉。手动创建线程有两个问题第一线程创建和销毁的成本很高。一个线程从创建到销毁涉及系统调用、内存分配、资源清理频繁创建销毁线程会浪费大量资源。第二线程数量无法控制。如果并发请求特别多每个请求都创建一个线程系统资源很快会被耗尽甚至导致 OOM。线程池的作用就是复用线程、控制线程数量、管理线程的生命周期。好比一个公司不可能每来一个客户就重新招聘一个人而是有一个固定规模的员工团队谁来服务都可以。5.2 ThreadPoolExecutor 的七个核心参数Java 最核心的线程池实现是ThreadPoolExecutor它有七个参数每一个都必须理解透彻。new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new LinkedBlockingQueue(100), // 任务队列 Executors.defaultThreadFactory(), // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 );七个参数分别是核心线程数、最大线程数、空闲线程存活时间、存活时间单位、任务队列、线程工厂、拒绝策略。当一个任务提交到线程池时处理流程是这样的先判断核心线程数是否已满没满就直接创建核心线程执行任务满了就把任务放入队列队列也满了就尝试创建新线程但是总线程数不能超过最大线程数如果线程数已经达到最大值队列也满了就触发拒绝策略。核心线程数怎么定有一个经验公式CPU 密集型任务设置成 CPU 核数 1IO 密集型任务设置成 CPU 核数 × 2 左右。因为 IO 密集型任务大部分时间在等待 IO线程多一点也不太会增加 CPU 竞争。5.3 四种拒绝策略以及生产环境选哪种JDK 提供了四种拒绝策略AbortPolicy直接抛出 RejectedExecutionException 异常这是默认策略。 CallerRunsPolicy谁提交的任务谁去执行不是丢弃而是让提交任务的线程自己执行起到一个天然限流的作用。 DiscardPolicy直接丢弃任务什么都不做。 DiscardOldestPolicy丢弃队列中最老的任务然后重新提交新任务。实际生产中最常用的是 CallerRunsPolicy因为任务不会被无故丢弃执行压力还能反馈到提交方起到背压的效果。AbortPolicy 比较暴力如果调用方没有做好异常处理任务就悄悄丢了。5.4 Executors 的快捷方法为什么不建议用很多教材只教Executors.newFixedThreadPool()这种快捷方式但阿里 Java 开发规范明确禁止在生产环境使用原因是使用了无界队列。newFixedThreadPool 和 newSingleThreadExecutor 的队列是LinkedBlockingQueue容量是 Integer.MAX_VALUE相当于没有上限。如果任务提交速度超过处理速度队列会无限堆积内存迟早被撑爆。newCachedThreadPool 的线程数是 Integer.MAX_VALUE如果任务特别多会创建海量线程同样容易 OOM。所以面试官问“Executors 和 ThreadPoolExecutor 选哪个”时答案不是选哪个而是直接用 ThreadPoolExecutor 手动指定参数这样才能根据业务场景精确控制。6. 并发容器与原子类JDK 的现成方案6.1 ConcurrentHashMap 和 Hashtable 有什么区别讲到并发场景下的 Map很多人马上想到 Hashtable 或者使用Collections.synchronizedMap()。这两种方式性能都很差因为它们直接在方法级别加上了一把大锁多个线程根本没法并发读。ConcurrentHashMap 是 JDK 提供的专门用于并发场景的 Map它的锁粒度要细得多。JDK 8 之后ConcurrentHashMap 抛弃了分段锁改用 CAS synchronized 只锁住数组的每个桶节点读操作不加锁所以并发度非常高。实际开发中只要涉及多线程共享 Map直接用 ConcurrentHashMap 就对了不要再用 Hashtable 了。这是一条铁律。6.2 原子类的核心CAS 无锁编程java.util.concurrent.atomic包下面提供了很多原子类比如 AtomicInteger、AtomicLong、AtomicReference。它们的核心原理是 CASCompare And Swap也就是比较并交换。CAS 操作包含三个值内存位置 V、预期原值 A、新值 B。执行时如果内存位置 V 的值等于预期原值 A就把 V 的值更新为 B否则不做任何操作。这个操作是硬件层面支持的原子指令所以效率很高。但 CAS 有一个经典的 ABA 问题线程一读到值 A线程二把值改成 B 又改回 A线程一再次执行 CAS 时发现值还是 A就认为没被修改过实际上已经被修改了两次。解决方法是使用AtomicStampedReference通过版本号来感知中间的修改。知道了 CAS 的原理你就能理解为什么 AtomicInteger 比 synchronized 做计数要好得多synchronized 会让线程阻塞CAS 是自旋等待在低竞争场景下 CPU 开销更小。6.3 ThreadLocal每个线程自己的变量副本ThreadLocal 不是用来解决并发冲突的恰恰相反它是为了让每个线程独享一份数据从而避免共享。举个例子一个 Web 请求从 Controller 到 Service 到 Dao如果都需要访问当前请求的用户信息每次调用都从参数传太麻烦。用 ThreadLocal 存一份同一个线程内所有地方都能取到。private static final ThreadLocalString CURRENT_USER new ThreadLocal(); public void processRequest(String userName) { CURRENT_USER.set(userName); try { // 同一线程内任何地方都能获取 String user CURRENT_USER.get(); } finally { // 防止内存泄漏必须清理 CURRENT_USER.remove(); } }ThreadLocal 底层是每个线程有一个 ThreadLocalMap所以每个线程访问的是自己的变量副本不存在竞争。但要注意使用 ThreadLocal 后一定要调用remove()清理。尤其是使用线程池时线程是复用的如果不清理下一次任务可能读到上一个任务遗留下来的数据。7. 面试高频考点与实战排查中的关键经验7.1 面试必背的并发编程八股文我把 Java 并发编程面试最常考的题目整理成了一张表你可以对着查漏补缺。进程与线程的区别 创建线程的三种方式及对比 线程生命周期与状态切换 synchronized 底层原理与锁升级过程 volatile 关键字的内存语义 synchronized 与 ReentrantLock 的区别 CAS 原理与 ABA 问题 ThreadLocal 原理与内存泄漏 线程池七大参数与执行流程 ExecutorService 的四大拒绝策略 CountDownLatch、CyclicBarrier、Semaphore 的区别 ConcurrentHashMap 的底层实现这里有个常问点CountDownLatch 和 CyclicBarrier 的区别。CountDownLatch 是一个线程等待多个线程完成计数器只减不加不能复用CyclicBarrier 是多个线程互相等待都到齐后再一起执行计数器可以循环复用。7.2 用 jstack 排查线程问题真到了线上环境你没法像本地那样直接调试多线程代码必须借助工具。最常用的是jstack命令它能输出 Java 进程当前所有线程的栈信息。比如线程卡住不动了用 jstack 看一眼输出如果发现大量线程处于 WAITINGparking状态说明都在等待某个条件如果看到很多线程 BLOCKED on 0x...说明在竞争同一把锁而且锁竞争异常激烈大概率是锁粒度设计有问题。再比如 CPU 飙高可以先top -Hp pid找到占用 CPU 最高的线程 id转成十六进制后再在 jstack 输出文件里搜索对应线程定位到具体代码。7.3 我踩过的最隐蔽的并发坑分享三个我实际踩过的坑每一个都很隐蔽写代码时根本不会注意。第一个坑SimpleDateFormat 不是线程安全的。很多人用它格式化日期多个线程共享同一个实例时可能抛出 NumberFormatException 或者得到错误结果。解决办法是用 ThreadLocal 为每个线程存一份或者用 JDK 8 的 DateTimeFormatter这个是线程安全的。第二个坑双重检查锁单例中使用 volatile。不加 volatile 可能拿到未初始化完成的对象。因为对象的创建在字节码层面是“分配内存、初始化、赋值给引用”三步CPU 和编译器可能做指令重排导致另一个线程看到引用非空但里面的属性还没初始化。第三个坑线程池里的异常被吞掉。如果 Runnable 任务中抛出非 RuntimeException 异常比如受检异常由于 run() 方法没有声明 throws异常会直接丢失。更有甚者任务执行抛异常后线程池的 Worker 线程会退出然后线程池会创建一个新线程从日志上看像一切正常实际上任务已经失败了。可以用execute()方法提交任务并配合自定义的ThreadFactory设置未捕获异常处理器或者使用submit()方法返回的 Future调用future.get()时检查异常。8. 一个完整的并发下载器案例8.1 需求分析最后用一个完整的案例把前面所有知识点串起来。需求很简单模拟一个并发文件下载器将一个文件分成 5 个分片并发下载所有分片全部完成后合并并提示“下载完成”。这个案例覆盖了线程创建、线程池、同步、协作、结果收集等几乎所有基础知识点。8.2 代码实现import java.util.ArrayList; import java.util.List; import java.util.concurrent.*; public class ConcurrentDownloader { private static final int DOWNLOAD_THREAD_COUNT 5; public static void main(String[] args) throws InterruptedException, ExecutionException { int totalPieces 5; ExecutorService threadPool new ThreadPoolExecutor( DOWNLOAD_THREAD_COUNT, DOWNLOAD_THREAD_COUNT, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(10), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy() ); CountDownLatch latch new CountDownLatch(totalPieces); ListFutureString futures new ArrayList(); for (int i 1; i totalPieces; i) { final int pieceNumber i; FutureString future threadPool.submit(() - { try { return downloadPiece(pieceNumber); } finally { latch.countDown(); } }); futures.add(future); } // 方式一通过 CountDownLatch 等待所有分片完成 latch.await(); System.out.println(所有分片下载完成开始合并...); // 方式二通过 Future.get() 获取各分片结果 for (FutureString future : futures) { String result future.get(); System.out.println(分片结果 result); } // 合并并输出结果 mergeFile(); threadPool.shutdown(); } private static String downloadPiece(int pieceNumber) throws InterruptedException { Thread.sleep(500); System.out.println(分片 pieceNumber 下载完成); return 分片 pieceNumber -OK; } private static void mergeFile() { System.out.println(文件合并完成); } }这段代码把本章所有核心知识点都用上了线程池负责管理线程和任务队列CountDownLatch 保证“所有分片都完成”这个条件Future 负责收集每个分片的执行结果finally里调countDown防止异常导致主线程卡死。8.3 运行结果分析运行这段代码你会在控制台看到分片随机顺序完成打印但“开始合并”一定在所有分片完成之后这就验证了 CountDownLatch 的等待语义。你再试着把 latch.countDown() 从 finally 里挪到 try 块最后然后让某个分片抛个异常你会发现主线程永远在 await这就是我前面强调“countDown 必须放 finally”的原因。把这个案例做完并发编程基础就算真正入门了后面再去学 AQS、并发包源码、JMM 底层模型就会顺畅很多。我在带学生时发现能把线程池 CountDownLatch Future 组合起来写出一个完整功能的人再去理解什么锁升级、什么内存屏障只是时间早晚的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价