Java 多线程:从基本概念到避坑指南
多核的机器,现在已经非常常见了。即使是一块手机,也都配备了强劲的多核处理器。通过多进程和多线程的手段,就可以让多个 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++的动作就可以翻译成以下的字节码。
这还只是代码层面的。如果再加上 CPU 每核的各级缓存,这个执行过程会变得更加细腻。如果我们希望执行完i++
之后,再执行i--
,仅靠初级的字节码指令,是无法完成的。我们需要一些同步手段。
上图就是 JMM 的内存模型,它分为主存储器(Main Memory)和工作存储器(Working Memory)两种。我们平常在 Thread 中操作这些变量,其实是操作的主存储器的一个副本。当修改完之后,还需要重新刷到主存储器上,其他的线程才能够知道这些变化。
1.3 Java 中常见的线程同步方式
为了完成 JMM 的操作,完成线程之间的变量同步,Java 提供了非常多的同步手段。
Java 的基类 Object 中,提供了 wait 和 notify 的原语,来完成 monitor 之间的同步。不过这种操作我们在业务编程中很少遇见
使用 synchronized 对方法进行同步,或者锁住某个对象以完成代码块的同步
使用 concurrent 包里面的可重入锁。这套锁是建立在 AQS 之上的
使用 volatile 轻量级同步关键字,实现变量的实时可见性
使用 Atomic 系列,完成自增自减
使用 ThreadLocal 线程局部变量,实现线程封闭
使用 concurrent 包提供的各种工具,比如 LinkedBlockingQueue 来实现生产者消费者。本质还是 AQS
使用 Thread 的 join,以及各种 await 方法,完成并发任务的顺序执行
从上面的描述可以看出,多线程编程要学的东西可实在太多了。幸运的是,同步方式虽然千变万化,但我们创建线程的方式却没几种。
第一类就是 Thread 类。大家都知道有两种实现方式。第一可以继承 Thread 覆盖它的 run 方法;第二种是实现 Runnable 接口,实现它的 run 方法;而第三种创建线程的方法,就是通过线程池。
其实,到最后,就只有一种启动方式,那就是 Thread。线程池和 Runnable,不过是一种封装好的快捷方式罢了。
多线程这么复杂,这么容易出问题,那常见的都有那些问题,我们又该如何避免呢?下面,我将介绍 10 个高频出现的坑,并给出解决方案。
2. 避坑指南
2.1. 线程池打爆机器
首先,我们聊一个非常非常低级,但又产生了严重后果的多线程错误。
通常,我们创建线程的方式有 Thread,Runnable 和线程池三种。随着 Java1.8 的普及,现在最常用的就是线程池方式。
有一次,我们线上的服务器出现了僵死,就连远程 ssh,都登录不上,只能无奈的重启。大家发现,只要启动某个应用,过不了几分钟,就会出现这种情况。最终定位到了几行让人啼笑皆非的代码。
有位对多线程不太熟悉的同学,使用了线程池去异步处理消息。通常,我们都会把线程池作为类的静态变量,或者是成员变量。但是这位同学,却将它放在了方法内部。也就是说,每当有一个请求到来的时候,都会创建一个新的线程池。当请求量一增加,系统资源就被耗尽,最终造成整个机器的僵死。
这种问题如何去避免?只能通过代码 review。所以多线程相关的代码,哪怕是非常简单的同步关键字,都要交给有经验的人去写。即使没有这种条件,也要非常仔细的对这些代码进行 review。
2.2. 锁要关闭
相比较 synchronized 关键字加的独占锁,concurrent 包里面的 Lock 提供了更多的灵活性。可以根据需要,选择公平锁与非公平锁、读锁与写锁。
但 Lock 用完之后是要关闭的,也就是 lock 和 unlock 要成对出现,否则就容易出现锁泄露,造成了其他的线程永远了拿不到这个锁。
如下面的代码,我们在调用 lock 之后,发生了异常,try 中的执行逻辑将被中断,unlock 将永远没有机会执行。在这种情况下,线程获取的锁资源,将永远无法释放。
正确的做法,就是将 unlock 函数,放到 finally 块中,确保它总是能够执行。
由于 lock 也是一个普通的对象,是可以作为函数的参数的。如果你把 lock 在函数之间传来传去的,同样会有时序逻辑混乱的情况。在平时的编码中,也要避免这种把 lock 当参数的情况。
2.3. wait 要包两层
Object 作为 Java 的基类,提供了四个方法wait
wait(timeout)
notify
notifyAll
,用来处理线程同步问题,可以看出 wait 等函数的地位是多么的高大。在平常的工作中,写业务代码的同学使用这些函数的机率是比较小的,所以一旦用到很容易出问题。
但使用这些函数有一个非常大的前提,那就是必须使用 synchronized 进行包裹,否则会抛出 IllegalMonitorStateException。比如下面的代码,在执行的时候就会报错。
类似的方法,还有 concurrent 包里的 Condition 对象,使用的时候也必须出现在 lock 和 unlock 函数之间。
为什么在 wait 之前,需要先同步这个对象呢?因为 JVM 要求,在执行 wait 之时,线程需要持有这个对象的 monitor,显然同步关键字能够完成这个功能。
但是,仅仅这么做,还是不够的,wait 函数通常要放在 while 循环里才行,JDK 在代码里做了明确的注释。
重点:这是因为,wait 的意思,是在 notify 的时候,能够向下执行逻辑。但在 notify 的时候,这个 wait 的条件可能已经是不成立的了,因为在等待的这段时间里条件条件可能发生了变化,需要再进行一次判断,所以写在 while 循环里是一种简单的写法。
带 if 条件的 wait 和 notify 要包两层,一层 synchronized,一层 while,这就是 wait 等函数的正确用法。
2.4. 不要覆盖锁对象
使用 synchronized 关键字时,如果是加在普通方法上的,那么锁的就是 this 对象;如果是加载 static 方法上的,那锁的就是 class。除了用在方法上,synchronized 还可以直接指定要锁定的对象,锁代码块,达到细粒度的锁控制。
如果这个锁的对象,被覆盖了会怎么样?比如下面这个。
上面的代码,由于在逻辑中,强行给锁listeners
对象进行了重新赋值,会造成锁的错乱或者失效。
为了保险起见,我们通常把锁对象声明成 final 类型的。
或者直接声明专用的锁对象,定义成普通的 Object 对象即可。
2.5. 处理循环中的异常
在异步线程里处理一些定时任务,或者执行时间非常长的批量处理,是经常遇到的需求。我就不止一次看到小伙伴们的程序执行了一部分就停止的情况。
排查到这些中止的根本原因,就是其中的某行数据发生了问题,造成了整个线程的死亡。
我们还是来看一下代码的模板。
在 loop 函数中,执行我们真正的业务逻辑。当执行到某个 task 的时候,发生了异常。这个时候,线程并不会继续运行下去,而是会抛出异常直接中止。在写普通函数的时候,我们都知道程序的这种行为,但一旦到了多线程,很多同学都会忘了这一环。
值得注意的是,即使是非捕获类型的NullPointerException
,也会引起线程的中止。所以,时刻把要执行的逻辑,放在 try catch 中,是个非常好的习惯。
2.6. HashMap 正确用法
HashMap 在多线程环境下,会产生死循环问题。这个问题已经得到了广泛的普及,因为它会产生非常严重的后果:CPU 跑满,代码无法执行,jstack 查看时阻塞在 get 方法上。
至于怎么提高 HashMap 效率,什么时候转红黑树转列表,这是阳春白雪的八股界话题,我们下里巴人只关注怎么不出问题。
网络上有详细的文章描述死循环问题产生的场景,大体因为 HashMap 在进行 rehash 时,会形成环形链。某些 get 请求会走到这个环上。JDK 并不认为这是个 bug,虽然它的影响比较恶劣。
如果你判断你的集合类会被多线程使用,那就可以使用线程安全的 ConcurrentHashMap 来替代它。
HashMap 还有一个安全删除的问题,和多线程关系不大,但它抛出的是 ConcurrentModificationException,看起来像是多线程的问题。我们一块来看看它。
上面的代码会抛出异常,这是由于 HashMap 的 Fail-Fast 机制。如果我们想要安全的删除某些元素,应该使用迭代器。
2.7. 线程安全的保护范围
使用了线程安全的类,写出来的代码就一定是线程安全的么?答案是否定的。
线程安全的类,只负责它内部的方法是线程安全的。如我我们在外面把它包了一层,那么它是否能达到线程安全的效果,就需要重新探讨。
比如下面这种情况,我们使用了线程安全的 ConcurrentHashMap 来存储计数。虽然 ConcurrentHashMap 本身是线程安全的,不会再出现死循环的问题。但 addCounter 函数,明显是不正确的,它需要使用 synchronized 函数包裹才行。
这是开发人员常踩的坑之一。要达到线程安全,需要看一下线程安全的作用范围。如果更大维度的逻辑存在同步问题,那么即使使用了线程安全的集合,也达不到想要的效果。
2.8. volatile 作用有限
volatile 关键字,解决了变量的可见性问题,可以让你的修改,立马让其他线程给读到。
虽然这个东西在面试的时候问的挺多的,包括 ConcurrentHashMap 中队 volatile 的那些优化。但在平常的使用中,你真的可能只会接触到 boolean 变量的值修改。
千万不要把它用在计数或者线程同步上,比如下面这样。
这段代码在多线程环境下,是不准确的。这是因为 volatile 只保证可见性,不保证原子性,多线程操作并不能保证其正确性。
直接用 Atomic 类或者同步关键字多好,你真的在乎这纳秒级别的差异么?
2.9. 日期处理要小心
很多时候,日期处理也会出问题。这是因为使用了全局的 Calendar,SimpleDateFormat 等。当多个线程同时执行 format 函数的时候,就会出现数据错乱。
为了改进,我们通常将 SimpleDateFormat 放在 ThreadLocal 中,每个线程一份拷贝,这样可以避免一些问题。当然,现在我们可以使用线程安全的 DateTimeFormatter 了。
2.10. 不要在构造函数中启动线程
在构造函数,或者 static 代码块中启动新的线程,并没有什么错误。但是,强烈不推荐你这么做。
因为 Java 是有继承的,如果你在构造函数中做了这种事,那么子类的行为将变得非常魔幻。另外,this 对象可能在构造完毕之前,出递到另外一个地方被使用,造成一些不可预料的行为。
所以把线程的启动,放在一个普通方法,比如 start 中,是更好的选择。它可以减少 bug 发生的机率。
End
wait 和 notify 是非常容易出问题的地方,
编码格式要求非常严格。synchronized 关键字相对来说比较简单,但同步代码块的时候依然有许多要注意的点。这些经验,在 concurrent 包所提供的各种 API 中依然实用。我们还要处理多线程逻辑中遇到的各种异常问题,避免中断,避免死锁。规避了这些坑,基本上多线程代码写起来就算是入门了。
许多 java 开发,都是刚刚接触多线程开发,在平常的工作中应用也不是很多。如果你做的是 crud 的业务系统,那么写一些多线程代码的时候就更少了。但总有例外,你的程序变得很慢,或者排查某个问题,你会直接参与到多线程的编码中来。
我们的各种工具软件,也在大量使用多线程。从 Tomcat,到各种中间件,再到各种数据库连接池缓存等,每个地方都充斥着多线程的代码。
即使是有经验的开发,也会陷入很多多线程
的陷阱。因为异步会造成时序的混乱,必须要通过强制的手段达到数据的同步。多线程运行,首先要保证准确性,使用线程安全的集合进行数据存储;还要保证效率,毕竟使用多线程的目标就是如此。
希望本文中的这些实际案例,让你对多线程的理解,更上一层楼。
作者:小姐姐味道
链接:https://juejin.cn/post/7005369339747106853
来源:掘金
评论