Java多线程:从基本概念到避坑指南

news2025/8/9 22:54:01

本文为掘金社区首发签约文章,未获授权禁止转载

多核的机器,现在已经非常常见了。即使是一块手机,也都配备了强劲的多核处理器。通过多进程和多线程的手段,就可以让多个CPU同时工作,来加快任务的执行。

多线程,是编程中一个比较高级的话题。由于它涉及到共享资源的操作,所以在编码时非常容易出现问题。Java的concurrent包,提供了非常多的工具,来帮助我们简化这些变量的同步,但学习应用之路依然充满了曲折。

本篇文章,将简单的介绍一下Java中多线程的基本知识。然后着重介绍一下初学者在多线程编程中一些最容易出现问题的地方,很多都是血泪经验。规避了这些坑,就相当于规避了90%凶残的多线程bug。

1. 多线程基本概念

1.1 轻量级进程

在JVM中,一个线程,其实是一个轻量级进程(LWP)。所谓的轻量级进程,其实是用户进程调用系统内核,所提供的一套接口。实际上,它还要调用更加底层的内核线程(KLT)。

实际上,JVM的线程创建销毁以及调度等,都是依赖于操作系统的。如果你看一下Thread类里面的多个函数,你会发现很多都是native的,直接调用了底层操作系统的函数。

下图是JVM在Linux上简单的线程模型。

可以看到,不同的线程在进行切换的时候,会频繁在用户态和内核态进行状态转换。这种切换的代价是比较大的,也就是我们平常所说的上下文切换(Context Switch)。

1.2 JMM

在介绍线程同步之前,我们有必要介绍一个新的名词,那就是JVM的内存模型JMM。

JMM并不是说堆、metaspace这种内存的划分,它是一个完全不同的概念,指的是与线程相关的Java运行时线程内存模型。

由于Java代码在执行的时候,很多指令都不是原子的,如果这些值的执行顺序发生了错位,就会获得不同的结果。比如,i++的动作就可以翻译成以下的字节码。

getfield      // Field value:I
iconst_1
iadd
putfield      // Field value:I
复制代码

这还只是代码层面的。如果再加上CPU每核的各级缓存,这个执行过程会变得更加细腻。如果我们希望执行完i++之后,再执行i--,仅靠初级的字节码指令,是无法完成的。我们需要一些同步手段。

上图就是JMM的内存模型,它分为主存储器(Main Memory)和工作存储器(Working Memory)两种。我们平常在Thread中操作这些变量,其实是操作的主存储器的一个副本。当修改完之后,还需要重新刷到主存储器上,其他的线程才能够知道这些变化。

1.3 Java中常见的线程同步方式

为了完成JMM的操作,完成线程之间的变量同步,Java提供了非常多的同步手段。

  1. Java的基类Object中,提供了wait和notify的原语,来完成monitor之间的同步。不过这种操作我们在业务编程中很少遇见
  2. 使用synchronized对方法进行同步,或者锁住某个对象以完成代码块的同步
  3. 使用concurrent包里面的可重入锁。这套锁是建立在AQS之上的
  4. 使用volatile轻量级同步关键字,实现变量的实时可见性
  5. 使用Atomic系列,完成自增自减
  6. 使用ThreadLocal线程局部变量,实现线程封闭
  7. 使用concurrent包提供的各种工具,比如LinkedBlockingQueue来实现生产者消费者。本质还是AQS
  8. 使用Thread的join,以及各种await方法,完成并发任务的顺序执行

从上面的描述可以看出,多线程编程要学的东西可实在太多了。幸运的是,同步方式虽然千变万化,但我们创建线程的方式却没几种。

第一类就是Thread类。大家都知道有两种实现方式。第一可以继承Thread覆盖它的run方法;第二种是实现Runnable接口,实现它的run方法;而第三种创建线程的方法,就是通过线程池。

其实,到最后,就只有一种启动方式,那就是Thread。线程池和Runnable,不过是一种封装好的快捷方式罢了。

多线程这么复杂,这么容易出问题,那常见的都有那些问题,我们又该如何避免呢?下面,我将介绍10个高频出现的坑,并给出解决方案。

2. 避坑指南

2.1. 线程池打爆机器

首先,我们聊一个非常非常低级,但又产生了严重后果的多线程错误。

通常,我们创建线程的方式有Thread,Runnable和线程池三种。随着Java1.8的普及,现在最常用的就是线程池方式。

有一次,我们线上的服务器出现了僵死,就连远程ssh,都登录不上,只能无奈的重启。大家发现,只要启动某个应用,过不了几分钟,就会出现这种情况。最终定位到了几行让人啼笑皆非的代码。

有位对多线程不太熟悉的同学,使用了线程池去异步处理消息。通常,我们都会把线程池作为类的静态变量,或者是成员变量。但是这位同学,却将它放在了方法内部。也就是说,每当有一个请求到来的时候,都会创建一个新的线程池。当请求量一增加,系统资源就被耗尽,最终造成整个机器的僵死。

void realJob(){
    ThreadPoolExecutor exe = new ThreadPoolExecutor(...);
    exe.submit(new Runnable(){...})
}
复制代码

这种问题如何去避免?只能通过代码review。所以多线程相关的代码,哪怕是非常简单的同步关键字,都要交给有经验的人去写。即使没有这种条件,也要非常仔细的对这些代码进行review。

2.2. 锁要关闭

相比较synchronized关键字加的独占锁,concurrent包里面的Lock提供了更多的灵活性。可以根据需要,选择公平锁与非公平锁、读锁与写锁。

但Lock用完之后是要关闭的,也就是lock和unlock要成对出现,否则就容易出现锁泄露,造成了其他的线程永远了拿不到这个锁。

如下面的代码,我们在调用lock之后,发生了异常,try中的执行逻辑将被中断,unlock将永远没有机会执行。在这种情况下,线程获取的锁资源,将永远无法释放。

private final Lock lock = new ReentrantLock();
void doJob(){
    try{
        lock.lock();
        //发生了异常
        lock.unlock();
    }catch(Exception e){
    }
}
复制代码

正确的做法,就是将unlock函数,放到finally块中,确保它总是能够执行。

由于lock也是一个普通的对象,是可以作为函数的参数的。如果你把lock在函数之间传来传去的,同样会有时序逻辑混乱的情况。在平时的编码中,也要避免这种把lock当参数的情况。

2.3. wait要包两层

Object作为Java的基类,提供了四个方法wait wait(timeout) notify notifyAll ,用来处理线程同步问题,可以看出wait等函数的地位是多么的高大。在平常的工作中,写业务代码的同学使用这些函数的机率是比较小的,所以一旦用到很容易出问题。

但使用这些函数有一个非常大的前提,那就是必须使用synchronized进行包裹,否则会抛出IllegalMonitorStateException。比如下面的代码,在执行的时候就会报错。

final Object condition = new Object();
public void func(){
	condition.wait();
}
复制代码

类似的方法,还有concurrent包里的Condition对象,使用的时候也必须出现在lock和unlock函数之间。

为什么在wait之前,需要先同步这个对象呢?因为JVM要求,在执行wait之时,线程需要持有这个对象的monitor,显然同步关键字能够完成这个功能。

但是,仅仅这么做,还是不够的,wait函数通常要放在while循环里才行,JDK在代码里做了明确的注释。

重点:这是因为,wait的意思,是在notify的时候,能够向下执行逻辑。但在notify的时候,这个wait的条件可能已经是不成立的了,因为在等待的这段时间里条件条件可能发生了变化,需要再进行一次判断,所以写在while循环里是一种简单的写法。

final Object condition = new Object();
public void func(){
	synchronized(condition){
		while(<条件成立>){
			condition.wait();
		}
	}
}
复制代码

带if条件的wait和notify要包两层,一层synchronized,一层while,这就是wait等函数的正确用法。

2.4. 不要覆盖锁对象

使用synchronized关键字时,如果是加在普通方法上的,那么锁的就是this对象;如果是加载static方法上的,那锁的就是class。除了用在方法上,synchronized还可以直接指定要锁定的对象,锁代码块,达到细粒度的锁控制。

如果这个锁的对象,被覆盖了会怎么样?比如下面这个。

List listeners = new ArrayList();

void add(Listener listener, boolean upsert){
    synchronized(listeners){
        List results = new ArrayList();
        for(Listener ler:listeners){
        ...
        }
        listeners = results;
    }
}
复制代码

上面的代码,由于在逻辑中,强行给锁listeners对象进行了重新赋值,会造成锁的错乱或者失效。

为了保险起见,我们通常把锁对象声明成final类型的。

final List listeners = new ArrayList();
复制代码

或者直接声明专用的锁对象,定义成普通的Object对象即可。

final Object listenersLock = new Object();
复制代码

2.5. 处理循环中的异常

在异步线程里处理一些定时任务,或者执行时间非常长的批量处理,是经常遇到的需求。我就不止一次看到小伙伴们的程序执行了一部分就停止的情况。

排查到这些中止的根本原因,就是其中的某行数据发生了问题,造成了整个线程的死亡。

我们还是来看一下代码的模板。

volatile boolean run = true;
void loop(){
    while(run){
    	for(Task task: taskList){
            //do . sth
            int a = 1/0;
    	}
    }
}
复制代码

在loop函数中,执行我们真正的业务逻辑。当执行到某个task的时候,发生了异常。这个时候,线程并不会继续运行下去,而是会抛出异常直接中止。在写普通函数的时候,我们都知道程序的这种行为,但一旦到了多线程,很多同学都会忘了这一环。

值得注意的是,即使是非捕获类型的NullPointerException,也会引起线程的中止。所以,时刻把要执行的逻辑,放在try catch中,是个非常好的习惯。

volatile boolean run = true;
void loop(){
    while(run){
    	for(Task task: taskList){
    		try{
                //do . sth
                int a = 1/0;
    		}catch(Exception ex){
    			//log
    		}
    	}
    }
}
复制代码

2.6. HashMap正确用法

HashMap在多线程环境下,会产生死循环问题。这个问题已经得到了广泛的普及,因为它会产生非常严重的后果:CPU跑满,代码无法执行,jstack查看时阻塞在get方法上。

至于怎么提高HashMap效率,什么时候转红黑树转列表,这是阳春白雪的八股界话题,我们下里巴人只关注怎么不出问题。

网络上有详细的文章描述死循环问题产生的场景,大体因为HashMap在进行rehash时,会形成环形链。某些get请求会走到这个环上。JDK并不认为这是个bug,虽然它的影响比较恶劣。

如果你判断你的集合类会被多线程使用,那就可以使用线程安全的ConcurrentHashMap来替代它。

HashMap还有一个安全删除的问题,和多线程关系不大,但它抛出的是ConcurrentModificationException,看起来像是多线程的问题。我们一块来看看它。

Map<String, String> map = new HashMap<>();
map.put("xjjdog0", "狗1");
map.put("xjjdog1", "狗2");
 
for (Map.Entry<String, String> entry : map.entrySet()) {
    String key = entry.getKey();
    if ("xjjdog0".equals(key)) {
       map.remove(key);
    }
}
复制代码

上面的代码会抛出异常,这是由于HashMap的Fail-Fast机制。如果我们想要安全的删除某些元素,应该使用迭代器。


Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
   Map.Entry<String, String> entry = iterator.next();
   String key = entry.getKey();
   if ("xjjdog0".equals(key)) {
       iterator.remove();
   }
}
复制代码

2.7. 线程安全的保护范围

使用了线程安全的类,写出来的代码就一定是线程安全的么?答案是否定的。

线程安全的类,只负责它内部的方法是线程安全的。如我我们在外面把它包了一层,那么它是否能达到线程安全的效果,就需要重新探讨。

比如下面这种情况,我们使用了线程安全的ConcurrentHashMap来存储计数。虽然ConcurrentHashMap本身是线程安全的,不会再出现死循环的问题。但addCounter函数,明显是不正确的,它需要使用synchronized函数包裹才行。

private final ConcurrentHashMap<String,Integer> counter;
public int addCounter(String name) {
    Integer current = counter.get(name);
    int newValue = ++current;
    counter.put(name,newValue);
    return newValue;
}
复制代码

这是开发人员常踩的坑之一。要达到线程安全,需要看一下线程安全的作用范围。如果更大维度的逻辑存在同步问题,那么即使使用了线程安全的集合,也达不到想要的效果。

2.8. volatile作用有限

volatile关键字,解决了变量的可见性问题,可以让你的修改,立马让其他线程给读到。

虽然这个东西在面试的时候问的挺多的,包括ConcurrentHashMap中队volatile的那些优化。但在平常的使用中,你真的可能只会接触到boolean变量的值修改。

volatile boolean closed;  
  
public void shutdown() {   
    closed = true;   
}  
复制代码

千万不要把它用在计数或者线程同步上,比如下面这样。

volatile count = 0;
void add(){
    ++count;
}
复制代码

这段代码在多线程环境下,是不准确的。这是因为volatile只保证可见性,不保证原子性,多线程操作并不能保证其正确性。

直接用Atomic类或者同步关键字多好,你真的在乎这纳秒级别的差异么?

2.9. 日期处理要小心

很多时候,日期处理也会出问题。这是因为使用了全局的Calendar,SimpleDateFormat等。当多个线程同时执行format函数的时候,就会出现数据错乱。

SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

Date getDate(String str){
    return format(str);
}
复制代码

为了改进,我们通常将SimpleDateFormat放在ThreadLocal中,每个线程一份拷贝,这样可以避免一些问题。当然,现在我们可以使用线程安全的DateTimeFormatter了。

static DateTimeFormatter FOMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss");
public static void main(String[] args) {
    ZonedDateTime zdt = ZonedDateTime.now();
    System.out.println(FOMATTER.format(zdt));
}
复制代码

2.10. 不要在构造函数中启动线程

在构造函数,或者static代码块中启动新的线程,并没有什么错误。但是,强烈不推荐你这么做。

因为Java是有继承的,如果你在构造函数中做了这种事,那么子类的行为将变得非常魔幻。另外,this对象可能在构造完毕之前,出递到另外一个地方被使用,造成一些不可预料的行为。

所以把线程的启动,放在一个普通方法,比如start中,是更好的选择。它可以减少bug发生的机率。

End

wait和notify是非常容易出问题的地方,

编码格式要求非常严格。synchronized关键字相对来说比较简单,但同步代码块的时候依然有许多要注意的点。这些经验,在concurrent包所提供的各种API中依然实用。我们还要处理多线程逻辑中遇到的各种异常问题,避免中断,避免死锁。规避了这些坑,基本上多线程代码写起来就算是入门了。

许多java开发,都是刚刚接触多线程开发,在平常的工作中应用也不是很多。如果你做的是crud的业务系统,那么写一些多线程代码的时候就更少了。但总有例外,你的程序变得很慢,或者排查某个问题,你会直接参与到多线程的编码中来。

我们的各种工具软件,也在大量使用多线程。从Tomcat,到各种中间件,再到各种数据库连接池缓存等,每个地方都充斥着多线程的代码。

即使是有经验的开发,也会陷入很多多线程的陷阱。因为异步会造成时序的混乱,必须要通过强制的手段达到数据的同步。多线程运行,首先要保证准确性,使用线程安全的集合进行数据存储;还要保证效率,毕竟使用多线程的目标就是如此。

希望本文中的这些实际案例,让你对多线程的理解,更上一层楼。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/34017.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

户外运动耳机推荐、这几款耳机专为户外运动而生

随着夏日的艳阳离去&#xff0c;秋意渐浓。在这个最适合郊外野游&#xff0c;最适合户外运动的季节&#xff0c;我们当然不能继续做我们的宅男宅女&#xff0c;而应该放任自己到野地里去撒一回野。当然&#xff0c;对于没有数码产品一天都不能活的你来说&#xff0c;即使行走在…

debug(二)(还没完)

视频来源&#xff1a;尚硅谷_宋红康_IDEA2022版本的安装与使用 【【尚硅谷】IDEA2022全新版教程&#xff0c;兼容JDK17&#xff08;快速上手Java开发利器&#xff09;】 https://www.bilibili.com/video/BV1CK411d7aA?p16&vd_source581d732b20cb23e01428068f153a99ed I…

Spring Security(5)

您好&#xff0c;我是湘王&#xff0c;这是我的CSDN博客&#xff0c;欢迎您来&#xff0c;欢迎您再来&#xff5e; 经常上网的人都应该有这样的体验&#xff1a;很多网站或者APP只需要第一次登录时输入用户名和密码之后&#xff0c;后面很长一段时间内就不需要再次输入密码了。…

48种数据分析可视化图表

可视化对于数据分析师来说可能不是最重要的&#xff0c;重要的是你分析或挖掘出来的结果是否有效。在这基础之上就需要通过可视化恰当完整的表达见解。这里又有区别了&#xff1a;实用性和美观性哪个更重要&#xff1f;要我说实用性是第一位的&#xff0c;能用一个元素表达最好…

python中的GUI自动化工具介绍

目录 1.pyautoGui 2.Lackey 3.AXUI 4.winGuiAuto 5.pywinauto 6.总结 1.pyautoGui PyAutoGUI是一个纯Python的GUI自动化工具&#xff0c;通过它可以让程序自动控制鼠标和键盘的一系列操作来达到自动化测试的目的。PyAutoGui 也一个流行的跨平台库&#xff08;具有基于图…

Day5:写前端项目(html+css+js)-导航部分p1

目录 实现折叠按钮 medium screen size 实现折叠按钮 效果图&#xff1a; 如何在调整屏幕尺寸大小的时候&#xff0c;导航栏的列表会从横的变成三条杠杠。就像手风琴导航栏 手风琴button的操作 aria-expanded - Accessibility | MDN <button class"mobile-nav-tog…

【opencv】形态学重建案例-数糖果(细胞)个数

原始图片如下&#xff0c;要求是利用形态学重建方法数出糖果个数 step1&#xff1a;先进行测地膨胀 令F表示标记图像&#xff0c;令G表示模板图像。在讨论中&#xff0c;我们假设两幅图像都是二值图像&#xff0c;且F包含于G。标记图像相对于模板大小为1的测地膨胀定义为 F相对…

ssm分页实战

1. 插件 1. maven <!-- 分页插件 --><dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper</artifactId><version>5.2.0</version></dependency> 3. mybaits核心配置文件中或spring配置文件中…

这就叫速度,并发编程深度解析实战七天杀上 GitHub 榜首

并发编程&#xff01;在一二线互联网公司的面试中&#xff0c;逃避不了也是必问的面试题&#xff0c;而绝大部分程序员对并发编程的理解也都停留在使用阶段。那么今天团长给大家推荐的这份资料&#xff0c;绝对可以帮到你。 本书涵盖了六个特点&#xff1a; 特色一&#xff1a…

linux mysql5.7.25 主从复制_生产版本

文章目录一、安装配置1. 部署总览2. 下载软件3. 解压重命名4. 创建组5. 安装数据库6. 配置my.cnf7. 添加开机启动8. 配置数据木库9. 启动mysql10. 登录修改密码11. 允许远程连接二、master节点主从2.1. 主从复制账号创建2.2. 创建主从复制的账号2.3. 账号授权2.4. 查看同步bin-…

VUE的10个常用指令

01 v-once 说明&#xff1a;只渲染元素和组件一次。随后的重新渲染&#xff0c;元素/组件及其所有的子节点将被视为静态内容并跳过。这可以用于优化更新性能。 <!-- 单个元素 --> <span v-once>This will never change: {{msg}}</span> <!-- 有子元素 --&…

二进制逻辑运算和基本门电路

目录 基本门电路很重要&#xff0c;做内存扩展片选译码的时候会常用 一&#xff1a;逻辑非&#xff08;按位取反&#xff09; not 二&#xff1a;逻辑乘&#xff08;逻辑与&#xff09;按位求“与” 有零出零 and 三&#xff1a;逻辑或&#xff08;逻辑加) 有1出1 …

2023-2028年中国化工新材料行业发展前景与投资趋势分析报告

本报告由锐观咨询重磅推出&#xff0c;对中国化工新材料行业的发展现状、竞争格局及市场供需形势进行了具体分析&#xff0c;并从行业的政策环境、经济环境、社会环境及技术环境等方面分析行业面临的机遇及挑战。还重点分析了重点企业的经营现状及发展格局&#xff0c;并对未来…

JavaScript高级复习下(60th)

1、函数内 this 的指向 2、严格模式 1、什么是严格模式 JavaScript 除了提供正常模式外&#xff0c;还提供了 严格模式&#xff08;strict mode&#xff09;。ES5 的严格模式是采用具有限制性 JavaScript 变体的一种方式&#xff0c;即在严格的条件下运行 JS 代码。 严格模式…

说一下 ArrayDeque 和 LinkedList 的区别?

大家好&#xff0c;我是小彭。 在上一篇文章里&#xff0c;我们聊到了基于链表的 Queue 和 Stack 实现 —— LinkedList。那么 Java 中有没有基于数组的 Queue 和 Stack 实现呢&#xff1f;今天我们就来聊聊这个话题。 小彭的 Android 交流群 02 群已经建立啦&#xff0c;扫描…

APP到底有没有权限-恶意拷贝删除照片

作者&#xff1a;黑蛋 近期发生了一件比较恶劣的事情&#xff0c;某客户在某物上面买了一件东西&#xff0c;但是这个东西是假货&#xff0c;所以客户致电某物人工客服&#xff0c;并进行了录音&#xff0c;这时候某物试图通过自身的客户端软件&#xff0c;去删除客户手机上的…

DFP 数据转发协议应用实例 7.使用 DLS1x 与 VSxxx 设备的 LoRA 匹配

DFP 数据转发协议应用实例 7.使用 DLS1x 与 VSxxx 设备的 LoRA 匹配 DFP 是什么&#xff1f; 稳控科技编写的一套数据转发规则&#xff0c; 取自“自由转发协议 FFP&#xff08;Free Forward Protocol&#xff09;” &#xff0c;或者 DFP&#xff08;DoubleF Protocol&#x…

维格云单点登录SSO入门教程

功能简介 无代码维格云单点登录功能支持用户通过配置,将无代码维格云的帐号体系,和提供标准OAuth2.0认证服务系统、LDAP的帐号体系统一起来。 两种单点登录方式的配置见子文档:OAuth 2.0LDAP,本文只描述通用功能:全局单点登录、退出登录后跳转指定页面。 注:本功能为A…

ArcGIS综合制图教程,简单上手!

目的 1、了解专题地图组成的各个要素&#xff1b; 2、掌握ArcGIS编辑专题图的方法和步骤&#xff1b; 实习内容 使用ArcGIS生成1&#xff1a;200万比例尺的浙江省县级行政区划图&#xff0c;并输出成像文件。 实习步骤 一、将ArcGIS切换到Layout视图&#xff0c;并调整页面…

HTML、CSS学习笔记小结

目录 1&#xff0c;HTML 1.1 简单介绍 1.2 快速入门 1.3 基础标签 代码演示&#xff1a; 1.4 图片、音频、视频标签 代码演示&#xff1a; 1.5 超链接标签 代码演示&#xff1a; 1.6 列表标签 代码演示&#xff1a; 1.7 表格标签 代码演示&#xff1a; 1.8 布局标…