拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java多线程从入门到实战:创建、锁、线程池与排查全解析

JavaSE 系列写到第九篇前面把集合、IO、异常这些单线程场景下的“地基”都过了一遍这次终于轮到多线程。说实话线程是 JavaSE 里面最让人头大的部分之一初学者一上来就容易被并发问题绕晕但实际做项目之后你会发现线程不是炫技而是解决性能问题的刚需。这篇就把线程创建、同步机制、锁、线程池、常见排查手段全部串起来把我自己踩过的坑也一并写出来。零基础可以重点看前四章已经写过业务代码的可以直接跳到线程池和问题排查部分。1. 为什么 JavaSE 阶段一定要啃下多线程1.1 进程和线程别再搞混了很多初学者把进程和线程当成同一个东西面试时也经常说混。我一般用一个比喻来记进程像一家公司线程就是公司里的员工。一家公司会申请自己的办公楼、办公设备、营业执照这些资源是公司独有的员工在这家公司的场地里干活共享桌椅、会议室、饮水机。对应到操作系统进程是资源分配的最小单位线程是 CPU 调度的最小单位。同一个进程里的多个线程共享堆内存和方法区但每个线程有自己的虚拟机栈和程序计数器。Java 程序只要一运行其实就不只一个线程。除了你写的 main 方法所在的主线程JVM 还会启动 GC 线程、编译线程等后台线程。这一点很多人没意识到以为单线程程序就是纯单线程实际上 JVM 内部早就并发跑着了。1.2 多线程到底能解决什么问题一句话总结尽可能让 CPU 和 IO 设备别闲着。比如一个下载任务如果用单线程下载、解压、校验、写入磁盘只能按顺序执行。下载时要等网络数据解压时要等 CPU 计算写入磁盘时要等 IO 完成每一步之间充满了等待。如果拆成多个线程网络线程负责拉数据解压线程负责处理数据写入线程负责落盘这几个动作在时间上重叠执行整体耗时能大幅缩短。在 CPU 多核越来越普遍的今天多线程还能利用多核并行计算。我大学时写过一个单线程的图片转换工具处理 1000 张图片要好几分钟改成生产者-消费者模式的线程池后同样的机器时间直接压缩到原来的四分之一。这不是魔法只是把原本空闲的核心用起来了。1.3 但多线程不是万能药刚开始觉得多线程什么都能快结果一用就翻车。一个典型的反例是任务本身只需要几毫秒但创建线程要几十毫秒线程切换还要消耗 CPU最后整体性能反而更差。多线程适合的是“有等待”的场景比如网络请求、磁盘读写、数据库访问如果是纯 CPU 密集型的几行数学计算线程数量设置成“CPU 核心数 1”就足够了开太多反而频繁切换拖慢速度。而且并发会带来三个新问题原子性、可见性、有序性。后面要讲的 synchronized、volatile、Lock、并发工具类本质上都是在和这三个问题作斗争。能理解这一点JavaSE 的多线程就算入门了。2. 线程创建方式与生命周期从 new 到 terminated2.1 三种创建线程的方式对比第一种继承Thread类。写起来最简单但 Java 是单继承一旦继承了 Thread 就不能再继承其他类扩展性很差。实际项目中除非是临时验证很少直接这么写。class MyThread extends Thread { Override public void run() { System.out.println(Thread.currentThread().getName() 执行中); } } public class ThreadDemo { public static void main(String[] args) { new MyThread().start(); } }第二种实现Runnable接口。这是最常用的方式之一把任务逻辑和线程运行方式解耦适合“一个任务丢给多个线程执行”的场景。class Task implements Runnable { Override public void run() { System.out.println(Thread.currentThread().getName() 执行任务); } } Thread t new Thread(new Task()); t.start();第三种实现Callable接口配合FutureTask使用。Callable 和 Runnable 最大的区别是Callable 可以有返回值还允许抛出异常。用它可以拿到线程执行结果。CallableInteger callable new CallableInteger() { Override public Integer call() throws Exception { Thread.sleep(1000); return 2; } }; FutureTaskInteger futureTask new FutureTask(callable); new Thread(futureTask).start(); // get() 会阻塞当前线程直到拿到结果 Integer result futureTask.get();这里的坑是get()会一直阻塞等待结果。生产环境一定要给get加超时时间否则一旦任务卡死不退出主线程也会跟着卡死。2.2 线程生命周期六种状态Java 的Thread.State枚举定义了六个状态面试经常考但很多人只记住“新建、就绪、运行、阻塞、死亡”五个状态那是老说法。JDK 里真正的六种状态是状态说明NEW线程刚创建还没调用 start()RUNNABLE就绪状态 运行状态都算这里等待 CPU 时间片或正在执行BLOCKED被 synchronized 阻塞等待进入同步代码块WAITING无限期等待需要被显式唤醒TIMED_WAITING有限期等待时间到了会自动恢复TERMINATED线程执行结束画不出图就用一个现实场景来记你把任务书放在桌上NEW然后站起来排队等工位RUNNABLE好不容易拿到工位开始干活RUNNABLE 的“运行”子状态结果隔壁大哥占着一个共享工具不放手你只能站在门口等BLOCKED等太久不但不叫你还让你“等我通知”WAITING或者告诉你“等三分钟再过来”TIMED_WAITING最后活干完了走人TERMINATED。需要特别注意的是run()方法和start()方法天差地别。调用start()才是真正创建一个新线程让 JVM 去调度直接调用run()只会在当前线程里同步执行一遍方法体根本没有新线程。很多新手在这个问题上栽过跟头代码看着也执行了但程序还是单线程跑的。2.3 写线程方法时的几个细节重写run()或者实现call()时方法内部尽量不要写耗时操作却又不处理中断。比如Thread.sleep()会抛出InterruptedException有些人直接try-catch吞掉这在测试环境没问题在生产环境会导致线程无法响应中断信号该停的时候停不下来。另外线程的名字一定要设置。用ThreadFactory或者在启动前thread.setName(pay-query- id)都可以。后面排查线上问题jstack 打出来一看全是“Thread-0”“Thread-1”根本分不清哪个是业务线程定位问题会很痛苦。3. 并发安全synchronized、volatile 与 Lock 的取舍3.1 先复现一个并发问题先看一个经典场景两个线程同时对一个int count做count。理论上执行一万次结果应该是两万。但实际跑出来经常是两万少一点甚至差很多。public class UnsafeCounter { private int count 0; public void increment() { count; } public int getCount() { return count; } }原因不难理解count这一行看着是一条语句但在 JVM 里它分成了至少三步——读取 count 的值、把值加 1、把结果写回 count。两个线程可能同时读到同一个旧值然后各自加 1再各自写回最后 count 只增加了 1。这就是典型的原子性问题。解决思路有两类一类是把“读-改-写”做成一个不可分割的操作比如synchronized或ReentrantLock另一类是直接用AtomicInteger这种带 CAS 原子操作的并发类。前者适合复杂操作后者适合简单的数值累加。3.2 synchronized 的三种用法synchronized是 Java 内建的同步机制使用简单不会像 Lock 那样忘了 unlock 导致死锁。它有三种写法第一种修饰实例方法。锁的是this也就是当前对象。同一个对象调用这个方法多个线程之间会互斥不同对象调用互不影响。public synchronized void increment() { count; }第二种修饰静态方法。锁的是当前类的Class对象所有实例共享这一把锁。所以哪怕是不同对象调用同一个静态同步方法也是互斥的。public static synchronized void reset() { total 0; }第三种修饰代码块。这是最灵活的方式也是实际代码里出现最多的写法。可以自己指定锁对象缩小锁粒度减少不必要的阻塞。public void increment() { synchronized (this) { count; } }锁粒度越小并发度越高但也不是越小越好。如果锁的是一段包含多个步骤且必须作为一个整体执行的业务逻辑拆得太散反而会出现中间态导致数据不一致。判断标准就一条这段代码是不是一个“原子操作”的整体。3.3 volatile 只能解决可见性别指望它解决原子性volatile是另一个高频考点。它的作用是保证变量在多个线程之间的可见性一个线程修改了变量其他线程能立刻看到最新值。它还会禁止指令重排序在某些单例场景里很关键。但 volatile 不解决复合操作的原子性。很多人一听说 volatile 能保证可见性就把它用在计数器上结果并发问题照旧。正确用法比如一个开关变量private volatile boolean running true; public void stop() { running false; } public void run() { while (running) { // 循环处理 } }这里的running用 volatile 是合适的因为当前线程循环读取另一个线程修改不涉及“读出来再算再写回”这种复合操作。如果既要可见性又要原子性还是得用 synchronized 或 AtomicInteger。3.4 ReentrantLock 比 synchronized 好在哪synchronized简单可靠但功能有限。碰到这些场景我就用ReentrantLock需要等待锁超时、需要响应中断、需要公平锁、需要多个条件变量。ReentrantLock lock new ReentrantLock(); public void doSomething() { if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 拿不到锁时的兜底 } }注意tryLock()一定要放在try外面否则一旦在获取锁之前抛出异常finally里执行unlock()会直接报IllegalMonitorStateException。另外Lock 使用完必须手动释放这依赖finally块保证。如果真的忘了释放后果比 synchronized 更严重线程会一直持有锁不释放导致其他线程永久阻塞。ReentrantLock的名字里有个 Reentrant意思是可重入。同一个线程可以多次获取同一把锁比如方法 A 加了锁方法 A 内部又调用了方法 BB 也有同一把锁因为可重入不会把自己挡在外面。synchronized 同样是可重入的这点很多人会忽略。4. 线程池与并发工具类把线程管起来4.1 为什么不让你再 new Thread小项目里直接用new Thread不是不行但到了生产环境频繁创建和销毁线程的代价很高。线程的创建要分配栈空间销毁要释放资源如果任务数量大一万个任务就是一万个线程内存直接被吃光。线程池的核心思路是复用一批线程任务丢给队列线程从队列里取任务执行。这样既控制了并发数量又避免了频繁创建销毁的开销。就好比公司不会项目一来了就疯狂招人项目结束就全部裁掉而是养一个固定规模的团队按优先级排队接活。4.2 ThreadPoolExecutor 七个参数必须背下来JDK 的线程池核心类就是ThreadPoolExecutor构造方法有七个参数每个都有讲究new ThreadPoolExecutor( corePoolSize, // 核心线程数 maximumPoolSize, // 最大线程数 keepAliveTime, // 空闲线程存活时间 TimeUnit.SECONDS, // 时间单位 new ArrayBlockingQueue(100), // 任务队列 r - { Thread t new Thread(r); t.setName(order-worker); return t; }, // 线程工厂 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );七个参数的核心逻辑是核心线程数即使空闲也不会被回收的线程数量。最大线程数当任务队列满了池子里最多能扩展到多少个线程。空闲存活时间超过核心线程数的线程空闲多久会被回收。任务队列核心线程都在忙时新任务先排队。线程工厂用来创建线程主要目的是起个好名字方便排查。拒绝策略队列满且线程数也到上限了新任务怎么办。选参数时要先想清楚业务类型。IO 密集型的任务比如网络请求、数据库操作大多数时间在等待可以把核心线程数设大一点一般参考公式是 CPU 核心数 × 2CPU 密集型的计算任务核心线程数趋近于 CPU 核心数 1。这个公式不能直接照抄但它能帮你避免一开始就往大的调。4.3 不要迷信 Executors 工具类很多人为了省事直接用Executors.newFixedThreadPool(10)但这几个快捷方法在特定场景下是有坑的。newFixedThreadPool和newSingleThreadExecutor默认使用无界队列LinkedBlockingQueue。如果任务积压太多队列会无限增长最后 OOM。newCachedThreadPool更危险它的最大线程数是Integer.MAX_VALUE如果任务提交速度远大于处理速度会无限创建线程直接拖垮机器。所以我的习惯是核心业务线程池一律手动创建ThreadPoolExecutor把队列长度、拒绝策略都显式写清楚。拒绝策略一般用CallerRunsPolicy意思是队列满了以后新任务不在线程池里执行而是由提交任务的线程自己执行。这样做的好处是能起到“限流”的效果因为提交线程也会被占住不会再疯狂堆任务。4.4 CountDownLatch 和 Semaphore 的应用场景除了线程池java.util.concurrent包里有几个工具类也非常实用。CountDownLatch适合“等待多个线程都完成后再执行后续操作”。比如批量导出报表先开多个线程分别导出不同部分主线程等人齐了再合并压缩。用法是初始化一个倒数计数器每个线程完成后调用countDown()主线程调用await()等待。CountDownLatch latch new CountDownLatch(3); for (int i 0; i 3; i) { new Thread(() - { try { // 执行任务 } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println(三个任务都完成了);Semaphore是信号量用来控制同时访问某个资源的线程数。典型场景是数据库连接池假设最多只有 5 个连接可以用Semaphore保证最多 5 个线程在抢连接。Semaphore semaphore new Semaphore(5); semaphore.acquire(); try { // 使用数据库连接 } finally { semaphore.release(); }这两个工具类都不难难的是理解“什么时候该用哪个”。我的经验是等别人完成用 CountDownLatch控制同时访问量用 Semaphore。如果多个线程要互相等待、同时就绪后再一起开始则可以考虑 CyclicBarrier。5. 并发问题排查死锁、原子性问题与线程转储5.1 死锁是怎么发生的死锁经典案例是“哲学家吃饭”。两个线程各自持有一把锁又都在等对方手里的锁谁也不让程序就卡死了。class DeadLockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { new Thread(() - { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException e) { } synchronized (lockB) { System.out.println(线程1拿到两把锁); } } }).start(); new Thread(() - { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException e) { } synchronized (lockA) { System.out.println(线程2拿到两把锁); } } }).start(); } }这个程序跑起来经常不动就是因为线程 1 持有 lockA 等 lockB线程 2 持有 lockB 等 lockA。避免死锁最有效的办法是“所有线程按同一顺序获取锁”。比如都先拿 lockA再拿 lockB就不会互相卡住。5.2 拿不到线程现场排查就是瞎猜线上发现程序卡死第一件事不是猜代码而是拿到线程快照。JDK 自带命令jstack可以把当前 Java 进程里所有线程的状态打出来。使用步骤用jps找到 Java 进程的 PID。执行jstack PID jstack.log。打开日志搜索“deadlock”关键字或查看 BLOCKED 状态线程在哪里阻塞。如果能看到两个或多个线程互相持有对方需要的锁那就基本定位死锁了。死锁日志里的“Found one Java-level deadlock”非常显眼找到后按照日志里提示的代码行去修就行。5.3 ThreadLocal 的内存泄漏坑ThreadLocal也是并发场景里常用的工具但很多人不知道它有内存泄漏风险。每个线程都有一个 ThreadLocalMapkey 是 ThreadLocal 对象的弱引用value 是强引用。如果 ThreadLocal 对象本身被回收了key 变成 null但 value 还留在 ThreadLocalMap 里线程不结束这个 value 就永远无法被回收。典型场景是在一个长期存活的应用服务器线程里用了 ThreadLocal却忘记调用remove()。一次请求的数据被下一个请求读到轻则数据串了重则内存持续增长。解决办法很简单用完务必remove()尤其是在 finally 块里。ThreadLocalString threadLocal new ThreadLocal(); try { threadLocal.set(请求ID); // 业务逻辑 } finally { threadLocal.remove(); }5.4 高并发计数器应该选什么前面那个count的问题如果只是统计次数、访问量这种简单操作最简单的是用AtomicIntegerprivate AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); }如果并发量极高、线程数非常多AtomicInteger在 CAS 竞争激烈时也会出现性能瓶颈这时候可以用LongAdder。它把单一计数变量拆成多个单元不同线程分散累加到不同单元最后再汇总性能更好。Java 8 以后LongAdder已经是高并发计数器的首选。从性能上排序是synchronized最重AtomicInteger中等LongAdder在高并发下更快。但不能只看性能还要考虑代码清晰度。如果是小规模并发用synchronized完全没问题。6. 写在最后的几条线程实操体会这一篇没有按老套路的“总结”只想分享几个我实际写过并发代码后留下的习惯。第一条写任何多线程代码之前先画清楚谁能改什么数据、谁依赖谁的结果。线程问题十个里有八个是数据竞争不是代码语法错误。把共享数据标出来用锁保护的范围画清楚比闭眼写然后再用 jstack 查要省时间得多。第二条锁的范围宁小勿大。很多初学容易把整个方法都加 synchronized结果并发度极低。正确做法是只锁真正会同时读写的临界区代码。锁的范围越大其他线程等待时间越长系统吞吐量越低。第三条给线程、线程池、锁都起一个有意义的名字。听起来很琐碎但线上真实排查经历会告诉你一个好名字能让你从几百行线程堆栈里三秒锁定问题而一堆“Thread-0”只能让人抓狂。第四条不要为了用线程而用线程。如果你的任务本身没有等待、没有并发冲突多线程只会引入更多复杂度。JavaSE 阶段学多线程重点不是把所有代码都改成多线程而是真正理解资源竞争和协调的核心逻辑。把这些基础打牢后面看 Spring 的线程池、微服务里的异步处理都会顺畅很多。JavaSE 系列到这儿线程部分算是一个阶段性收尾。下一篇我更想聊一聊基于这些基础知识延伸出来的实战场景比如生产环境里接口响应慢了怎么判断是线程池配置问题还是锁竞争问题。希望这一篇能帮你在面对线程问题时至少知道该从哪里下手。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门