news 2026/10/15 7:21:18

Java多线程入门:并发安全与线程池实战笔记

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java多线程入门:并发安全与线程池实战笔记

说实话,写到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信息,比会写任何语法都重要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 7:21:18

UOS时间同步服务器搭建:chrony+北斗+国密SM2实战

1. 项目概述&#xff1a;为什么在UOS上搭时间同步服务器不是“可选项”&#xff0c;而是“必选项”你刚装好国产UOS操作系统&#xff0c;系统时间显示正常&#xff0c;桌面右下角的时钟走得很稳——看起来一切都没问题。但某天你发现&#xff0c;公司内网的几台UOS终端之间日志…

作者头像 李华
网站建设 2026/10/15 7:21:01

Go系统编程实战:云原生环境下的配置分发与健康检查组件

第37篇了。这个系列走到现在&#xff0c;前面已经覆盖了Go的基础语法、并发模型、网络编程的基础套路&#xff0c;也聊过云原生的基本概念。这篇我想把两件事真正捏到一起来——在云原生环境里&#xff0c;用Go去写一个跟底层系统打交道、跑在容器里、配合编排系统完成生命周期…

作者头像 李华
网站建设 2026/10/15 7:20:55

基于Flask与Selenium的电商比价系统:从爬虫到DeepSeek智能问答

做电商比价系统这个想法&#xff0c;我一开始以为核心难点在爬虫。等真正动手做完这套基于 Flask 与 Python 的多平台电商比价可视化系统&#xff0c;我才反应过来&#xff1a;爬数据只是地基&#xff0c;真正费精力的是把不同平台千奇百怪的商品信息拉到同一个坐标系里比较&am…

作者头像 李华
网站建设 2026/10/15 7:20:50

微信小程序毕业设计实战:食堂用餐综合服务平台完整开发指南

1. 从毕业设计的选题挣扎&#xff0c;到小程序上线的完整链路每年到了毕业设计开题季&#xff0c;总能看到一批同学在几个经典选题之间反复横跳&#xff1a;管理系统、商城、博客论坛……不是说这些不好&#xff0c;而是同质化太严重&#xff0c;答辩时老师看一眼题目就知道你要…

作者头像 李华
网站建设 2026/10/15 7:20:32

匿名光流传感器使用手册:从悬停漂移到定点稳如磐石

简介&#xff1a;《匿名光流传感器使用手册》是匿名科创 ANO TC 团队为 V3.10 版本光流模块编写的官方用户手册&#xff0c;面向无人机开发者、飞控调试人员及低空悬停应用爱好者&#xff0c;帮助解决光流数据解耦、测距抗干扰与模块配置等实际问题。资源包内含 1 个 PDF 文件&…

作者头像 李华