免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Java线程API与守护线程:从基础方法到虚拟线程实战

Java线程API与守护线程:从基础方法到虚拟线程实战 并发编程一直是Java面试的重灾区也是日常开发绕不开的坎。今天不聊玄乎的理论就实打实拆解线程里最常用的一批方法API顺带把守护线程这茬彻底讲明白。不管是刚入门准备面试还是写了好几年业务代码想补补基础这篇都能给你点干货。我会按照实际开发中的使用频率把start、join、sleep、yield、interrupt这些方法一个个过一遍看看它们到底怎么用、踩过什么坑再把守护线程的应用场景和注意事项讲透。最后顺便聊聊线程池、虚拟线程这些进阶玩法帮助你把并发这把双刃剑握稳。1. 线程的基础与创建方式1.1 两种创建线程的方式创建线程是并发编程的第一课也是最容易让人想当然的地方。很多新手上来就继承Thread类写一个子类然后重写run方法代码倒是能跑但稍微一重构就发现很别扭。原因很简单Java是单继承一旦你继承了Thread这个类就不能再继承任何别的类了。而且继承Thread会把任务代码和线程耦合在一起逻辑上并不清晰。我更推荐实现Runnable接口的方式。把任务本身和线程分离——Runnable只定义你要做的事情至于谁来执行、怎么调度交给线程去管。这样任务可以被复用到多个线程里也可以和线程池配合非常实用。写起来也简单public class MyTask implements Runnable { Override public void run() { System.out.println(任务执行中线程 Thread.currentThread().getName()); } } // 启动 Thread t new Thread(new MyTask()); t.start();到了JDK 5以后又多了Callable和Future这套组合主要解决一个痛点Runnable的run方法没有返回值也无法抛出受检异常。如果你需要线程执行完返回一个结果用Callable更合适CallableInteger task () - { // 模拟耗时计算 Thread.sleep(500); return 42; }; ExecutorService executor Executors.newFixedThreadPool(1); FutureInteger future executor.submit(task); Integer result future.get(); // 阻塞等待结果把Callable和Runnable放在一起对比会更直观对比项RunnableCallable返回值无void有泛型指定异常处理只能内部处理可以抛出受检异常启动方式Thread或线程池submit线程池submit返回Future底层原理run()是voidcall()返回结果实际开发中Runnable用得最多因为大部分线程任务是干完就算Callable适合那种需要拿结果做后续判断的场景比如并行计算一批数据汇总排名结果。不过要提醒一点future.get()会阻塞当前线程直到任务结束如果任务迟迟不完成记得加超时参数比如future.get(2, TimeUnit.SECONDS)避免把主线程卡死。1.2 线程的生命周期状态线程不是一出生就一直跑的它会在几种状态之间来回切换。Java官方定义了六种状态NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。iOS开发的朋友可能会联想到进程状态机但其实思路是一样的就是给你一张地图知道线程现在到底在哪才好排查问题。用一个生活场景来记线程就像一个人在办事大厅里办事。NEW这个人刚刚创建了档案还没去取号。RUNNABLE已经叫到号了可能在排队也可能正在窗口办理。在Java里RUNNABLE其实是可运行的统一状态不细分CPU是否在执行。BLOCKED他想进同步区但发现锁被别人拿走了只能在门口等着。对应synchronized没抢到锁的情况。WAITING他办完一部分手续后自己选择等别人通知。对应wait()这类操作没有时间限制。TIMED_WAITING设置了等待时间比如等5秒、等3分钟对应sleep(时间)、join(时间)、wait(超时时间)。TERMINATED事情办完了状态结束。状态之间不是随便跳的比如WAITING只能靠notify或interrupt唤醒。我见过不少新手排查死锁时看jstack打印出一堆WAITING就懵了其实只要对照这张状态转换关系就能快速定位是哪个线程在等什么。如果想在代码里亲眼看到这些状态可以用一个小例子跑一遍public class ThreadStateDemo { public static void main(String[] args) throws Exception { Thread t new Thread(() - { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println(before start: t.getState()); t.start(); System.out.println(after start: t.getState()); Thread.sleep(500); System.out.println(while sleeping: t.getState()); t.join(); System.out.println(after finish: t.getState()); } }跑完你会发现状态从NEW到RUNNABLE到TIMED_WAITING再到TERMINATED变化一目了然。理解这些状态对后面讲join、interrupt以及守护线程的终止机制都特别重要。2. 核心方法API完整解析2.1 start()与run()的区别这是线程方法里最最基本的区别但隔三差五还是有人踩坑。记住一句话start()才是真正启动一个新线程而run()只是普通方法调用。如果你直接调用run()比如Thread t new Thread(() - System.out.println(执行线程 Thread.currentThread().getName())); t.run();控制台会打印出main根本没启动新线程。因为run()就是线程执行体里的那段逻辑你直接调它和调用一个普通方法没有任何区别。而start()会做一系列底层操作创建一条独立执行路径然后自动调用你的run()。这背后的原理涉及JVM和操作系统层面start()会调用native方法把新线程交给操作系统调度然后由这个新线程来执行run()。所以调用顺序必须是先start()不能重复start()否则会抛IllegalThreadStateException。我用过一次就是因为不小心在循环里重复启动线程直接分布式事务被中断排查了好久才发现是调用方对start的误用。另外要注意start()方法本身是synchronized的能保证线程状态的一致性。这也是为什么官方建议不要重写start()你只需要重写run()。2.2 sleep()与yield()的正确姿势sleep和yield都是让线程暂停的东西但两者差别非常大。sleep是躺平指定时间yield是礼貌地退让一下。Thread.sleep(long millis)的作用是让当前线程进入TIMED_WAITING状态停止执行指定毫秒数。这个方法有个必须记住的坑它会抛出InterruptedException所以必须捕获或向上抛。实际开发中我经常看到有人把它写在方法签名里但业务代码根本没地方抛导致程序异常退出。正规做法是捕获后根据业务决定是否恢复中断标志。sleep的本质是带着锁睡觉它不会释放已经持有的synchronized锁。很多人误以为线程睡了锁就会放开导致其他线程还是进不来结果引发不必要的等待。举个例子synchronized (lock) { Thread.sleep(1000); // 锁还在手上别人进不来 }如果你想让出锁并等待那应该用wait()不是sleep()。这俩名称听着像机制完全不同。yield()则更佛系它只会让出当前线程对CPU的占用让同优先级或更高优先级的线程先跑。不过调度器可以忽略yield也就是说它只是给个建议不保证发生。我在实际项目中几乎不用yield因为现在的操作系统线程调度已经很智能了你抢来抢去反而降低性能。它更适合那些大量自旋操作的场景比如自旋锁里配合一下否则意义不大。2.3 join()与interrupt()的协作机制join()是用来等线程结束的。假设主线程要等子线程计算完再继续你就可以在主线程里调用子线程的join()。它内部会调用wait()把当前线程放到WAITING状态直到被join的线程终止后再唤醒。用起来很简单Thread t new Thread(() - { /* 模拟耗时任务 */ }); t.start(); t.join(); // 主线程等待t执行完 System.out.println(t已结束);join可以带超时参数比如join(1000)意思是最多等你1秒超时我继续走。这在配合异步任务时很实用防止无限期等待。有个坑是join()也是抛InterruptedException的不要把异常吞了不处理。interrupt()可能是线程方法里最被误解的一个。它不是强制停止线程而是向目标线程发出中断信号把它的中断状态置为true。目标线程怎么响应完全取决于它自己。比如Thread.sleep()、wait()、join()这些会立刻抛出InterruptedException并清空中断状态而普通业务代码如果不判断中断状态就感觉不到。所以interrupt是协作式的目的是提供一种优雅的停止线程的方式。比如在循环里做耗时任务的线程应该定期检查Thread.currentThread().isInterrupted()响应后自己退出循环、释放资源。while (!Thread.currentThread().isInterrupted()) { // 处理任务 }这样业务线程自己掌控清理时机比强制打断安全得多。旧版有个stop()方法能直接杀死线程但早已废弃因为它可能导致数据不一致、锁不释放。从那以后Java团队就一直在推广协作式中断。2.4 获取线程信息的常用方法线程API里还有一批小工具方法不显眼但用得多。Thread.currentThread()静态方法拿到当前执行这段代码的线程对象。想在run方法里打印线程名就靠它。getName() / setName()获得或设置线程名称。强烈建议给线程起个有意义的名字不要线程-0、线程-1。否则线上日志排查时你根本不知道是哪个业务线程在报错。getId()返回线程唯一ID通常从0开始递增底层是线程的标识。isAlive()判断线程是否已经启动且尚未终止。在调用join前可以先判断一下避免一些无效等待。setPriority(int)设置优先级范围1-10默认5。但这只是一个建议操作系统未必理会别指望它能决定绝对优先级。isDaemon()判断线程是否守护线程和下文要讲的守护线程直接相关。还有一对特殊的方法isInterrupted()和静态方法Thread.interrupted()。前者返回中断状态但不清除标志后者返回当前线程的中断状态并清除标志。两者差别极关键处理中断时千万别用混。3. 守护线程幕后英雄的正确用法3.1 什么是守护线程守护线程英文名daemon thread听着挺神秘其实可以理解为工具人线程。它存在的意义是为其他用户线程服务比如垃圾回收线程、JIT编译线程、定时监控线程都是典型守护线程。最关键的特性是当进程中所有用户线程都结束了JVM会自动退出剩下的守护线程也会被强杀。换句话说守护线程不会阻止进程退出它会随着主流程的消亡而消亡。这和用户线程不死、JVM不退出形成鲜明对比。举一个生活化的例子你用户线程进入一家餐厅点菜后厨里一直有一个帮厨守护线程在备菜。如果你吃饱离开了这家散伙了帮厨也就不需要存在。帮厨不会拦着你说等一下我还没备完菜。这就是守护线程。3.2 如何创建守护线程创建守护线程非常简单只需要在调用start()之前调用setDaemon(true)Thread daemon new Thread(() - { while (true) { // 做一些后台清理工作 } }); daemon.setDaemon(true); daemon.start();这里有个强制规范必须在start()之前调用setDaemon(true)否则会抛出IllegalThreadStateException。原因也不难理解线程一旦启动它的状态已经给JVM注册过了你到底是不是守护线程必须得先确定。另外守护线程里创建的子线程默认也是守护线程。这个继承规则在源码里有体现Thread构造时会继承父线程的daemon状态。所以你要不要在守护线程里再开子线程得想清楚——那些子线程也可能在你主流程结束的时候被强制停止。3.3 守护线程的应用场景守护线程适合做可有可无的后台任务。如果任务没做完但用户线程已经全部退出了那这个任务就算做了一半也无所谓。常见的落地场景有定时清理临时文件每隔一段时间清理一次临时目录主程序退出了清理任务自然也就该停了。心跳检测客户端向服务端发送心跳包保持连接如果客户端退出心跳线程当然不需要继续。日志采集和监控指标上报比如你需要在应用运行时采集一些指标但应用停了这些采集线程还活着就没意义了所以设为守护线程很合理。本地缓存刷新比如每隔5分钟拉一次远端配置刷新本地缓存主进程退出了刷新也停止。以心跳检测为例写一个简化的守护线程Thread heartbeat new Thread(() - { while (!Thread.currentThread().isInterrupted()) { try { // 发送心跳包到注册中心 System.out.println(发送心跳时间 System.currentTimeMillis()); Thread.sleep(3000); } catch (InterruptedException e) { break; } } }); heartbeat.setDaemon(true); heartbeat.start();注意既然main函数本身也是一个用户线程如果main线程结束守护线程也会跟着终止。所以在演示时你最好让main循环sleep一段时间否则守护线程还没来得及跑就被杀掉了。3.4 守护线程的注意事项与坑守护线程用得好是帮手用不好是隐患。我在实际项目里踩过几个很典型的坑这里给大家提个醒。第一不要在守护线程里执行关键业务。比如订单状态更新、数据持久化、事务提交这些一旦被强制中断可能造成数据不一致。守护线程的猝死可以发生在用户线程退出后的任意时刻不会给你机会优雅地完成清理。如果你有必须做完的最后一步比如把缓冲区的数据落盘应该用普通线程并且在退出前做好flush。第二不要在finally块里做必须完成的工作。很多程序员习惯在finally里释放资源、记录日志这在普通线程里没问题但在守护线程里可能不会执行——因为JVM退出时不会等守护线程的finally块跑完它可能刚停在半截就被强杀。第三守护线程不能覆盖所有业务尽量不要用做核心中间件的线程池。比如Kafka消费者、Netty的EventLoop如果你把它们设置为守护线程一旦主线程闪退流量直接断掉连重试的机会都没有。第四注意线程计数和资源泄漏。守护线程不阻止JVM退出但也意味着它可能在你根本不需要的时候还在跑。比如一个循环里不断创建守护线程没有正确停止公共的资源线程内存照样会涨。所以该加的终止条件还是一定要加别因为它是守护线程就放任不管。4. 线程协作与同步避免死锁的实战经验4.1 线程互斥与同步基础线程之间如果要共享变量、一起操作同一个对象不加控制就会出现数据错乱。比如两个线程同时执行count最终结果可能少加了几次这就是典型的并发安全问题。解决思路就是互斥同一时刻只允许一个线程进入临界区。Java提供了两种基础方案synchronized关键字和Lock接口。synchronized是内置锁用简单但不够灵活Lock是显式锁可以超时、可中断、支持公平锁。举一个经典的账户取钱例子class Account { private int balance 1000; public synchronized void withdraw(int amount) { if (balance amount) { balance - amount; System.out.println(取款成功余额 balance); } else { System.out.println(余额不足); } } }synchronized加在方法上锁的是this对象。如果两个线程同时调用同一个Account对象的withdraw第二个线程会被挡在门外等第一个线程执行完再进去。只要锁住的是同一个对象互斥才能成立。Lock的写法更灵活Lock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }用Lock时千万别忘了finally里解锁否则一旦出现异常锁就永远不释放别的线程也永远进不来。这也是为什么很多资深开发说宁可synchronized也不要乱用Lock——省事还安全。4.2 死锁的产生与排查死锁是并发编程里的魔鬼。简单说就是一群线程互相持有对方等待的锁谁也没法推进卡死不动了。经典的生活场景就是过独木桥两个人迎面走互不相让谁都过不去。死锁需要满足四个条件互斥条件、持有并等待条件、不可剥夺条件、循环等待条件。只要打破任何一个死锁就能解除。实际操作中我们一般破坏的是循环等待——也就是保证所有线程获取锁的顺序一致。下面是一段会产生死锁的演示代码public class DeadlockDemo { private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { Thread t1 new Thread(() - { synchronized (lockA) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println(t1拿到了B锁); } } }); Thread t2 new Thread(() - { synchronized (lockB) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println(t2拿到了A锁); } } }); t1.start(); t2.start(); } }两个人一个拿A等B一个拿B等A互相等待程序就永远卡在那里。遇到死锁怎么排查记住一个万金油工具jstack。先用jps找到Java进程PID然后执行jstack PID它会打印所有线程的栈轨迹。如果看到明显的循环等待比如两个线程都在等某个锁后面都会标注deadlock。看多了你会很快定位到是哪个方法顺序不一致导致的。还有一个重要原则尽量缩小锁的范围不要在锁内做耗时操作比如IO、sleep。锁保护的是共享数据不是整段方法。把耗时操作挪到锁外既能降低死锁概率也能提升并发度。4.3 线程通信wait/notify与LockCondition互斥解决的是同时访问同一资源的问题但业务里还有一类问题叫调度协作。比如一个生产者往队列里放元素消费者取元素队列满了生产者要停下来等队列空了消费者要停下来等这就需要线程之间发信号。经典写法是wait/notify。注意wait和notify必须持有同一个对象的锁也就是必须写在synchronized代码块内否则会抛IllegalMonitorStateException。class MessageQueue { private final LinkedListString queue new LinkedList(); public synchronized void put(String msg) throws InterruptedException { while (queue.size() 10) { // 用while而不是if因为唤醒后要重新检查 wait(); } queue.add(msg); notifyAll(); } public synchronized String take() throws InterruptedException { while (queue.isEmpty()) { wait(); } String msg queue.removeFirst(); notifyAll(); return msg; } }这里有两处容易踩坑。第一wait()会释放锁sleep不会这一点必须记死第二条件判断要用while循环不能用if不然被唤醒后条件可能已经被别的线程改变了产生假唤醒。如果用了Lock就没法直接用wait/notify得用Condition。它把wait/notify拆成了更细的多个等待队列支持公平等待和中断。Lock lock new ReentrantLock(); Condition notEmpty lock.newCondition(); Condition notFull lock.newCondition();生产者在notFull上等待消费者在notEmpty上等待唤醒时只唤醒对应那一批线程效率更高。5. 线程池与虚拟线程并发编程的进阶选择5.1 ThreadPoolExecutor核心参数配置线程池是管理线程的大管家用得好能大幅提升性能用不好反而拖垮系统。核心是ThreadPoolExecutor七个参数读懂后就很好掌控。corePoolSize核心线程数。线程池初始化后即使空闲也会一直保留的线程数。maximumPoolSize最大线程数。当队列满了线程数才会往这个最大值扩。keepAliveTime非核心线程空闲多久回收配合时间单位生效。workQueue任务队列当核心线程全部忙碌新任务就先进队列。threadFactory线程工厂常用于自定义线程名和守护状态。handler饱和策略队列和最大线程都满了时的处理方式。执行流程是这样的提交任务时如果当前线程数小于corePoolSize直接创建核心线程执行线程数达到corePoolSize后任务进入队列队列满了再创建非核心线程直到maximumPoolSize再满就触发拒绝策略。配置参数是一个经典难题。我一般不会盲目套公式而是先分析任务是CPU密集型还是IO密集型。CPU密集型比如大量计算、加密解密线程数最好不要超过CPU核数设为核心数1或2。IO密集型比如网络请求、文件读写线程大部分时间在等IO可以放宽到CPU核数×2或更多。用一个简单的估算IO密集型线程数 CPU核心数 × (1 平均等待时间 / 平均计算时间)。以Tomcat为例默认值往往配置较大因为请求大多是IO等待。另外优先使用有界队列比如ArrayBlockingQueue防止无限堆积打爆内存。拒绝策略一般选CallerRunsPolicy——不是直接把任务丢弃而是交给提交任务的线程去执行相当于退回给调用方自己处理。这能保证任务不丢也有背压的效果。5.2 阻塞队列的选择策略线程池的workQueue选什么类型直接影响任务排队的行为和资源消耗。常见的阻塞队列有这几种队列类型是否有界特点适用场景ArrayBlockingQueue有界固定大小内存可控适合需要严格限制任务量的场景比如短信推送LinkedBlockingQueue可配置有界/无界链表结构支持大量任务默认无界时要小心内存爆炸SynchronousQueue容量为0不存任务直接交给线程适合要求立即执行的场景吞吐量高PriorityBlockingQueue无界支持优先级排序任务有优先级时使用DelayQueue无界定时延迟执行适合延迟任务、定时器实际工程里我最常用的组合是核心线程数有界ArrayBlockingQueue再加CallerRunsPolicy。因为无界队列一旦请求暴增队列会无限膨胀最终导致OOM。比如曾经有个同事用Executors.newFixedThreadPool底层就是无界LinkedBlockingQueue高并发下来直接把内存撑爆了。这也是为什么官方并不推荐直接用Executors的便捷方法而是建议手动创建ThreadPoolExecutor。SynchronousQueue很有意思它本身不存任务每个插入操作必须等一个取出的操作。普通直连没有缓冲适合大量短任务、希望快速交接给线程执行的场景。但它配合maximumPoolSize使用否则容易产生大量瞬时线程。5.3 虚拟线程原理与使用心得JDK 21正式带来了虚拟线程这是并发编程的一件大事。简单说虚拟线程是JVM层面模拟出来的轻量级线程并不是每个虚拟线程都对应一个操作系统线程。操作系统线程可以理解成真正的员工而虚拟线程是一个任务清单由JVM自己调度员工去执行。传统线程池的核心问题是线程是稀缺资源每个线程都要占栈内存动辄几MB。当并发量达到成千上万时传统线程根本扛不住。而虚拟线程的栈可以在堆外动态扩展创建和阻塞几乎不占用额外系统资源所以你可以创建十万个甚至百万个虚拟线程这叫自由线程的思路。使用方式很简单ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); executor.submit(() - { // 每个任务都是一个虚拟线程 System.out.println(虚拟线程执行 Thread.currentThread().getName()); });对于大量IO密集型任务虚拟线程特别合适。以前你得小心翼翼调线程池参数比如DB连接池、外部API调用、文件读写每个并发点都怕把线程池打爆。用虚拟线程后你可以为每个请求单独创建一个虚拟线程写起来像同步代码却能承受极高的并发量。但虚拟线程也不是银弹。CPU密集型任务就别凑热闹了因为虚拟线程最终还是在操作系统线程上跑CPU本来就不多虚拟线程再多也不会快。另外锁竞争的问题依然存在如果大量虚拟线程争抢同一个锁反而可能比传统线程池更慢。我实际用下来最稳的场景就是那种一个请求里要并发调多个外部服务然后聚合结果的IO密集场景替换成本低、收益极其明显。6. 常见问题与排查技巧实录6.1 线程启动失败或重复启动最常见的错误是对同一个Thread对象调用两次start()会抛出IllegalThreadStateException。线程一旦启动状态就不是NEW了不能重新启动。你想再次执行同一任务重新new一个Thread。还有一类情况是线程变量被当作局部变量在循环中创建后没有启动。仔细检查一下代码确认start()确实被调用不要只写new Thread(runnable)就完了它不会自动开始。6.2 InterruptedException处理不当sleep、join、wait都会抛InterruptedException很多人直接catch后空着或者简单printStackTrace。这种处理非常糟糕因为异常被吞掉后中断状态也被清除了上层代码根本不知道线程被中断过。正确做法是在catch块里恢复中断标志try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 重新设置中断标志 // 根据需要处理或返回 }如果你不在catch里恢复标志线程池或其他协作方就不知道你被中断了可能造成任务继续运行或资源不释放。这个细节在很多线上问题排查里都能看到。6.3 守护线程意外终止守护线程最大的特点就是不阻止JVM退出所以很多人在main函数结束后守护线程刚跑到一半就没了。如果你发现守护线程的任务不执行先检查是不是主线程提前退出了。另外守护线程会被强制中止因此不要在里面做任何必须在退出前完成的操作。如果做了就会莫名丢失数据。6.4 如何优雅地停止线程很多人还在用Thread.stop()这里再强调一次stop已经废弃不要用。正确的方式是协作式中断用volatile标志或interrupt方法。volatile标志写起来直观class MyTask implements Runnable { private volatile boolean running true; Override public void run() { while (running) { // 执行任务 } } public void stop() { this.running false; } }这个flag必须用volatile保证其他线程能看到修改后的值。如果线程处在sleep或wait状态volatile标志帮不上忙就必须用interrupt来唤醒再退出。两者经常搭配使用。6.5 常见问题速查表现象可能原因排查命令/建议线程不启动忘了调用start()检查代码逻辑重复start抛异常对同一Thread调用两次start重新创建Thread对象sleep没生效在锁内sleep锁没释放改用wait或缩短锁范围死锁锁顺序不一致或嵌套jstack排查统一锁顺序线程池内存暴涨用了无界队列改有界队列任务的finally没执行在守护线程被捕杀重要操作不用守护线程主线程提前退出没join或没awaitTermination添加join或shutdown管理排查并发现场我最常用的三板斧先看日志再抓线程转储Thread Dump最后看资源占用。抓线程转储的命令很关键jstack PID直接打印所有线程的栈轨迹死锁、阻塞都能看得清清楚楚。我个人在实际操作中的体会是并发编程最难的往往不是那些眼花缭乱的API而是搞清楚每一条线程当下的状态、在等什么资源、由谁唤醒。把线程方法和守护线程重新梳理一遍以后再去用线程池和虚拟线程你会发现很多概念其实都是相通的。建议你动手跑一跑上面的示例代码自己打几杯死锁日志用jstack抓一抓那种感觉比看一百篇文章都强。最后再分享一个小技巧写并发代码前先把哪些数据会被多个线程访问、如何保证可见性和原子性想清楚再去选同步手段你的代码会稳得多。
返回列表