说实话,写到DAY18我自己都有点恍惚。前半个月还在数组和循环里打转,每天对着控制台输出各种形状的三角形,今天终于摸到了Java里真正有"灵魂"的东西:多线程。学完之后再看之前写的单线程程序,感觉就像开了上帝视角,很多以前觉得"莫名其妙卡一下"的问题,一下子都有了答案。这篇日记就把我DAY18学的多线程基础、并发安全,还有几个踩进去又爬出来的坑,完整记录下来。
1. DAY18学习目标:为什么进度表上今天必须碰多线程
1.1 从单线程到多线程,横在中间的那道坎
我之前写的所有Java程序,本质上都是从上往下一条路走到黑。哪怕写了循环、调了方法、用了集合,程序执行的时候仍然是一个线程从头跑到尾,一条主线程把活全干了。这种模式简单归简单,但现实世界的程序根本不是这么运作的:比如一个网络服务器要同时处理几十个客户端请求,一个下载工具要同时下载多个文件,一个聊天软件要一边收发消息一边刷新界面。如果这些场景都排成队一个个来,用户体验会差到什么程度,不用我说你也懂。
DAY18的核心目标,就是搞明白怎么让Java程序同时干多件事,以及更重要的:多件事同时干的时候,怎么保证数据不出错。因为多线程真正的难点从来不是"怎么创建线程",而是"线程之间怎么协作、怎么互不干扰"。这一块学不扎实,后面接触服务器编程、数据库连接池、消息队列这些东西都会特别吃力。
1.2 今天用到的全部前置知识
在进入多线程之前,我心里其实要先过一遍之前学的东西能不能跟上。DAY18用到的前置知识包括:面向对象里的继承和接口(创建线程要继承Thread类或者实现Runnable接口)、匿名内部类和Lambda表达式(后两种创建线程的写法要频繁用到)、static关键字和共享变量的概念(多线程数据安全问题绕不开它)。另外异常处理也很重要,线程池相关API的调用会抛出各种异常,至少要知道怎么读堆栈信息。
如果这些前置知识点还没掌握扎实,学多线程的时候会非常痛苦。尤其是Lambda表达式,如果你还没搞明白,建议先回头补一下,不然今天写的代码,你可能会觉得像天书。
2. 多线程核心概念:先别写代码,把这些道理嚼碎
2.1 进程和线程的关系,一个生活化到不行的类比
进程和线程的区别,我花了很长时间才真正理解。后来我用一个类比彻底把它记住了:进程就是一个正在运行的应用程序,比如你打开了一个浏览器;线程就是浏览器里面同时加载的几个标签页。每个标签页都在干自己的事,互不干扰,但它们共享同一个浏览器进程的资源,比如网络连接、内存缓存。换到电脑术语就是:一个进程可以包含多个线程,同一个进程里的多个线程共享进程的内存空间,但每个线程又有自己独立的执行路径和栈空间。
这个"共享内存"的属性是把双刃剑。好处是线程间传递数据特别方便,几个线程读写同一个对象就行;坏处是如果多个线程同时修改同一个数据,彼此之间会打架,这种打架在专业术语里叫"竞态条件"。DAY18后半段我所有踩的坑,基本都栽在"共享内存"这四个字上。
2.2 创建线程的四种方式,直接对比清楚
Java里创建线程,网上随便一搜能搜出好几种说法,但真正核心的就四种,我把它们都写了一遍对比着看。
第一种是继承Thread类,重写run方法。这是最传统也最直白的写法,但有个致命缺点:Java是单继承,一旦继承了Thread,就不能再继承别的类了,扩展性很差。所以实际项目里这种写法已经用得越来越少。
第二种是实现Runnable接口,把任务逻辑写在run方法里,然后丢给一个Thread对象执行。这种写法解决了单继承的痛点,而且它把"任务"和"执行任务的线程"分开了,逻辑更清晰。我第一天学的时候印象最深的是,必须new Thread(runnableInstance)把Runnable包进去,再调用start方法,线程才会真正跑起来,直接调run方法只是普通的方法调用,不会开启新线程。
第三种是实现Callable接口。它跟Runnable最大的区别是:Callable的任务执行完之后可以返回一个结果,而且还能抛出异常。配合FutureTask或者线程池里的Future接口使用,可以拿到线程执行的返回值。第四种就是线程池,通过ExecutorService提交任务,把线程的创建和管理交给框架,不用自己new完就扔。这是以后实际开发里用得最多的一种,但前提是把前三种的原理吃透,不然直接上线程池会一头雾水。
2.3 线程生命周期,到底经历了哪些状态
学线程状态的时候,我看过一张图,说线程有新建、就绪、运行、阻塞、死亡五个状态,但真正写代码跑起来之后,我发现在JVM层面观察状态,远比这张图更细。JDK的Thread.State枚举里其实定义了六种:NEW(刚创建还没start)、RUNNABLE(就绪和运行中的统称)、BLOCKED(等待锁)、WAITING(无限期等待另一个线程唤醒)、TIMED_WAITING(带时间的等待)、TERMINATED(已结束)。
这里最容易误解的是RUNNABLE。很多初学者以为RUNNABLE就是正在CPU上执行,其实Java把"可以跑"和"正在跑"合并成了一个状态。一个线程就算被操作系统调度到等待队列里排队,只要没发生阻塞,它的状态也还是RUNNABLE。我在本地跑了一个只输出一句话的线程,然后用调试模式观察它的状态,发现几乎大部分时间它都停留在RUNNABLE,只是被操作系统轮换调度而已。
2.4 Thread类常用方法,哪些必须记牢
DAY18里我实际操作到的常用方法也就那五六个:start()用来启动一个新线程,run()是任务逻辑的入口但不要直接调用,sleep()让当前线程睡一会儿并抛出InterruptedException,join()让一个线程等另一个线程跑完再继续,yield()提示调度器让出CPU(注意只是提示,不一定真的让),isAlive()判断线程是否存活。
这里面我犯过一个特别蠢的错:在main线程里直接调用了t.run(),结果程序还是单线程跑,一点并发效果都没有。后来我才反应过来,run方法是给新线程回调用的,直接调用等于把任务塞给了当前线程执行。这也是很多新手面试时被问到的问题,一定要记住:start和run的区别是什么。
3. 并发安全实战:从卖票程序到锁机制的完整拆解
3.1 经典卖票程序:怎么把100张票卖得不重不漏
多线程最经典的入门案例就是卖票。我照着网上的例子写了一个:一个共享的ticketCount变量,初始值100,然后创建三个线程模拟三个售票窗口,每个窗口循环卖票,每卖一张让ticketCount减一。写完之后满怀期待地运行,结果立刻翻车。票数变成了负数,同一张票被卖出去了两次,输出日志乱成了一锅粥。
这个问题的根源就是我在2.1里说的竞态条件。每个线程执行ticketCount--这个操作时,表面上是一行代码,但在底层其实拆成了三步:读取ticketCount的当前值,把值减一,把新值写回内存。如果两个线程同时读到100,各自减一,又同时写回,那票数只会变成99,而不是98,等于凭空丢了一张票。如果其中一个线程读的时候正好赶上别的线程写到一半,各种匪夷所思的错误都会冒出来。
要解决这个问题,最直接的办法就是在操作共享数据的时候加锁。Java里最简单的锁就是synchronized关键字,它可以保证同一时刻只有一个线程能进入被它修饰的代码块,其他线程只能在外面排队等着。我给卖票的代码加了一个synchronized (this)同步块,把"判断余票大于0、票数减一、输出日志"这三步操作变成一个不可分割的整体,再运行时整个输出就恢复正常了。
3.2 synchronized的三种写法,我踩过的最大的坑
synchronized在Java里可以用在三个位置:修饰普通方法、修饰静态方法、修饰代码块。修饰普通方法时,锁默认是this这个实例对象;修饰静态方法时,锁是当前类的Class对象;修饰代码块时,锁是你手动指定的那个对象。
我踩过的大坑是用错了锁的对象。当时我写了一个卖票程序,把synchronized加在了普通方法上,然后创建了三个独立的Thread去执行。结果票数照样出错,我百思不得其解,查了半天才发现原因:我每次都new了一个新的Runnable实例,导致三个线程锁的是三个不同的对象,锁了个寂寞。正确的做法是多个线程共享同一个Runnable实例,或者干脆锁一个全局静态对象。从那以后我每次写同步代码,第一反应都是先问自己:所有线程锁的到底是不是同一个对象?这一个问题排查完,60%的并发问题都能解决。
3.3 Lock接口和ReentrantLock,比synchronized更灵活的锁
学完synchronized,我本来以为关于锁的内容就告一段落了,结果又碰到了Lock接口。以ReentrantLock为代表的显式锁,最大的优点是灵活。synchronized在进入同步块时自动加锁、退出时自动释放,而ReentrantLock需要主动调用lock()加锁,在finally块里调用unlock()释放锁。
为什么需要这种灵活性?因为synchronized在某些场景下不够用。比如一个方法里有多处提前return的逻辑,synchronized会在方法结束时自动释放锁,没问题;但如果用了lock(),你忘记在某个return之前unlock(),锁就永远锁住了,其他线程全部卡死。反过来看,ReentrantLock提供的tryLock(timeout)方法,可以在指定时间内尝试获取锁,拿不到就放弃,不会一直无限期等下去,这在处理复杂业务逻辑时非常实用。
DAY18我做了个简单的对比测试:用synchronized和ReentrantLock分别写一个计数器累加程序,各开10个线程,每个线程累加一万次。最后两者的结果都一样准确,但ReentrantLock的代码结构明显更复杂,必须手动释放锁。所以在能用synchronized解决的地方,我建议你还用synchronized,代码简洁不容易错,只有遇到高级场景再考虑Lock。
3.4 volatile关键字:它到底解决什么问题
除了加锁,还有一个关键字volatile值得单独说,因为面试里问得多,实际用起来也容易误解。volatile的作用是保证变量在多线程之间的"可见性":一个线程修改了变量,其他线程能立刻看到这个变化。代价是它不保证原子性,也就是说多个线程同时做count++这种复合操作,volatile是管不住的。
我写代码验证了一下这个现象:一个volatile的boolean标志,主线程把它改成true之后,子线程立刻就能感知到并跳出循环,这是visible的效果;但如果用volatile修饰一个int计数器,开10个线程各自累加一万次,最终结果永远不是十万,这就暴露了它不保证原子性的事实。所以在日常开发里,volatile最典型的用途是状态标志位,而不是计数器。如果你要保证计数器的原子性,老实加锁,或者用AtomicInteger这种原子类。
3.5 线程通信:wait和notify的正确打开方式
让多个线程各干各的不难,难的是让它们按顺序协作。DAY18最有意思的部分就是生产者消费者模式,我的实现里用了一个满容量为1的队列,生产者线程往队列里放数据,消费者线程从队列里取数据,两者交替执行,谁都不允许在队列满/空的情况下强行操作。
这里面的核心就是wait()和notify()这对组合。当队列满的时候,生产者调用wait()把自己挂起,释放掉锁;消费者取走数据后调用notify(),把等待的生产者唤醒。反过来也一样。这里头有几个必须注意的点:wait和notify必须放在synchronized代码块里,而且调用wait之前一定要想清楚"唤醒之后条件是否仍然成立",否则会出现假唤醒问题,这就是为什么官方文档建议把wait()放在while循环里,而不是if里。
4. 实操记录:从卖票到线程池,完整跑通一遍
4.1 线程池初体验,代码虽短但门道不少
前三种创建线程的方式都写完并跑通之后,我最后试了线程池。线程池的好处一句话就能说清:避免频繁创建和销毁线程的开销,让有限的线程去执行无限的任务。按固定线程数创建线程池的方式最简单:Executors.newFixedThreadPool(3)会创建一个能复用3个线程的线程池,提交4个任务,池里的线程会排队一个个执行。
我试着用线程池跑了一个批量下载模拟程序,20个任务丢进去,只看到3个线程在轮番输出执行日志。那一刻我才明显体会到线程池的威力:不用为每个任务都new线程,系统资源省了一大截。当然,实际生产中我更推荐用ThreadPoolExecutor去手动配置核心线程数、最大线程数、队列容量和拒绝策略,Executors的快捷方法虽然方便,但存在一些隐藏风险,比如无界队列可能导致内存溢出,这个后面我再专门写一篇细说。
4.2 生产者消费者完整代码,一步步讲清楚
我直接把我跑通的生产者消费者代码贴出来,每一步的用意都标在旁边,方便对照:
class Producer implements Runnable { private final Queue<Integer> queue; private final int capacity; private int data = 0; public Producer(Queue<Integer> queue, int capacity) { this.queue = queue; this.capacity = capacity; } @Override public void run() { while (true) { synchronized (queue) { while (queue.size() == capacity) { try { queue.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } queue.offer(++data); System.out.println("生产:" + data + ",队列大小:" + queue.size()); queue.notifyAll(); } // 加一点小休眠,方便观察执行顺序 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } class Consumer implements Runnable { private final Queue<Integer> queue; public Consumer(Queue<Integer> queue) { this.queue = queue; } @Override public void run() { while (true) { synchronized (queue) { while (queue.isEmpty()) { try { queue.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } Integer value = queue.poll(); System.out.println("消费:" + value + ",队列大小:" + queue.size()); queue.notifyAll(); } try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } }主方法里我创建了一个容量为1的队列,一个生产者线程,一个消费者线程,然后启动。运行结果很规整,生产一个、消费一个、再生产一个、再消费一个,交替出现,没有任何脏数据。我在这里特意用了synchronized(queue),让生产者和消费者锁同一个队列对象,这是这套模式能正常工作的大前提。
4.3 多窗口卖票的正确写法,含为什么static重要
卖票程序我最终改成了这样:定义一个共享的TicketSeller类,内部维护一个static的ticketCount变量,sellTicket方法用synchronized修饰,三个线程共享同一个TicketSeller实例。把ticketCount设置为static,是为了让它在所有线程之间真正只有一份拷贝,否则每个线程拿到的都是自己那份ticketCount,加锁也没意义。
还有一个细节是while循环里的判断顺序。我一开始把if(ticketCount > 0)写成了if(ticketCount != 0),结果程序出现负数票的时候完全没拦住。后来改成while循环加synchronized,双重保障:先判断是否有票,再进入同步块访问共享数据。其实如果同步块够大,把判断也包进去,一个while就够了,关键是不要在锁外面判断票数。
synchronized (this) { if (ticketCount > 0) { try { Thread.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println(Thread.currentThread().getName() + "卖出第" + ticketCount + "张票"); ticketCount--; } }为什么故意加一个Thread.sleep(10)?因为如果不加休眠,三个线程竞争锁太激烈,反而很难出现典型的错误场景。加上休眠之后,线程在卖票的过程中被打断,更容易暴露出同步缺失的问题,这是一个非常实用的调试技巧。
5. 踩坑实录:多线程这关,我栽了这四个跟头
5.1 死锁是怎么悄悄发生的
写多线程程序最怕的就是死锁,线程各自攥着一把锁不撒手,又都想拿对方的锁,结果全部卡死。DAY18我专门写了一个死锁案例来观察现象:两个线程,线程A先锁资源1再锁资源2,线程B先锁资源2再锁资源1。运行起来程序直接卡住,没有任何输出,CPU占用也不高,特别安静。
排查死锁的方法,我在IDEA里用到了jstack命令,先找到Java进程的PID,然后执行jstack dump线程快照。日志里很明显能看到线程A持有资源1的锁,等待资源2;线程B持有资源2的锁,等待资源1,互相等待的状态一目了然。解决死锁最通用的思路就是保证加锁顺序一致:制定一个全局的锁顺序,让所有线程都按同样的顺序获取锁,基本不会死锁。
5.2 ConcurrentModificationException:一边遍历一边修改的正确姿势
这个异常不是线程独有的,单线程里也可能出,但多线程下概率会大很多。起因是一个线程正在用迭代器遍历ArrayList,另一个线程同时往里添加元素,迭代器立刻抛ConcurrentModificationException。
解决方式有三种:第一种是用Collections.synchronizedList包装集合,但它只是给单独操作加锁,复合操作仍需手写同步;第二种是用CopyOnWriteArrayList这种并发容器,读操作无锁,写操作复制一份新数组,适合读多写少;第三种是尽量把遍历和修改放在同一个同步块里,避免并发修改。
5.3 HashMap死循环问题:只在多线程扩容时才出现
这个坑现在JDK8已经修复了一部分,但原理值得知道。早先的HashMap在并发扩容时,多个线程同时操作同一个链表,可能导致链表形成环状结构。下次查询某个key时,线程会在环上无限循环,CPU直接飙到100%。
我特意模拟了一下,用两个线程同时往一个HashMap里put大量不同的key,然后去查询这些key,结果程序很快就出现卡顿和CPU异常。换成ConcurrentHashMap之后,问题彻底消失。这给我的启发是:多线程环境下,HashMap这种非线程安全的集合,不是"要不要用并发类"的问题,是"必须用并发类"的问题。
5.4 线程安全问题的排查思路,整理成速查表
我自己总结了多线程问题排查的一套顺序,遇到并发bug时别再像无头苍蝇一样乱试。
| 现象 | 优先怀疑 | 排查手段 |
|---|---|---|
| 数据计数不准确、重复数据 | 共享变量缺少同步或同步对象不一致 | 检查synchronized锁对象,是否多个线程共用一个锁 |
| 程序卡死不动 | 死锁 | 用jstack dump线程快照,查找互相等待的锁ID |
| 遍历集合时报ConcurrentModificationException | 多线程并发修改集合 | 改用CopyOnWriteArrayList或同步块包住遍历 |
| CPU持续100% | 可能哈希环死循环或活锁 | 排查HashMap并发扩容,换ConcurrentHashMap |
| 子线程没有按预期退出 | volatile标志位变量未声明 | 确认标志变量是否volatile且线程可见 |
这个表不是什么高深理论,就是我今天一天踩坑的浓缩,希望能帮同样在学多线程的人节省一点debug时间。
6. DAY18学习复盘:现在回头看,有哪些不一样的感受
6.1 学多线程前后的认知变化
学完今天的内容,再回头看一开始写的单线程程序,我有一种很强烈的"世界观被刷新"的感觉。以前觉得程序就是一串按顺序执行的指令,现在意识到它完全可以是一堆并行协作的"小工人",每个工人有自己的任务,彼此通过锁和等待来协作。单线程的程序,就像一个小公司只有一个员工,再忙也是一件事一件事挨着做;多线程的程序,就像这个公司招了好几个员工,但员工之间如果协调不好,就可能出现两个人往同一个文件里写字互相覆盖,或者两个人都在等对方先动手的尴尬局面。
6.2 写给明天自己的一段话
多线程这块内容太多太深,一天不可能全部消化。DAY18我只是打了个基础:四种创建线程的方式、synchronized和Lock两种锁、volatile的作用、线程通信里wait和notify的基本用法、线程池的初步认识。接下来几天,我计划把重点放在线程池深挖、并发容器的使用场景、以及各种并发工具类的实战对比上。
这里再分享一个我今天下午踩完后觉得特别有用的小技巧:调试多线程程序时,不要只盯着输出日志看,最好在关键变量上加上断点,切到线程视图,留意每个线程停在哪个位置。很多看似随机出现的bug,其实在线程视图里都有规律可循。学会看线程Dump信息,比会写任何语法都重要。