Java开发之多线程的基本概念与如何避坑

1. 多线程基本概念
1.1 轻量级进程
在JVM中,一个线程,其实是一个轻量级进程(LWP)。所谓的轻量级进程,其实是用户进程调用系统内核,所提供的一套接口。实际上,java培训它还要调用更加底层的内核线程(KLT)。
实际上,JVM的线程创建销毁以及调度等,都是依赖于操作系统的。如果你看一下Thread类里面的多个函数,你会发现很多都是native的,直接调用了底层操作系统的函数。
下图是JVM在Linux上简单的线程模型。
Java开发之多线程的基本概念与如何避坑
文章图片

可以看到,不同的线程在进行切换的时候,会频繁在用户态和内核态进行状态转换。这种切换的代价是比较大的,也就是我们平常所说的上下文切换(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--,仅靠初级的字节码指令,是无法完成的。我们需要一些同步手段。
Java开发之多线程的基本概念与如何避坑
文章图片

上图就是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. 避坑指南
Java开发之多线程的基本概念与如何避坑
文章图片

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 map = new HashMap<>();
map.put("xjjdog0", "狗1");
map.put("xjjdog1", "狗2");
for (Map.Entry entry : map.entrySet()) {
String key = entry.getKey(); if ("xjjdog0".equals(key)) { map.remove(key); }

}
上面的代码会抛出异常,这是由于HashMap的Fail-Fast机制。如果我们想要安全的删除某些元素,应该使用迭代器。
Iterator iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry entry = iterator.next();
String key = entry.getKey();
if ("xjjdog0".equals(key)) {
iterator.remove();

}
}
2.7. 线程安全的保护范围
使用了线程安全的类,写出来的代码就一定是线程安全的么?答案是否定的。
线程安全的类,只负责它内部的方法是线程安全的。如我我们在外面把它包了一层,那么它是否能达到线程安全的效果,就需要重新探讨。
比如下面这种情况,我们使用了线程安全的ConcurrentHashMap来存储计数。虽然ConcurrentHashMap本身是线程安全的,不会再出现死循环的问题。但addCounter函数,明显是不正确的,它需要使用synchronized函数包裹才行。
private final ConcurrentHashMap counter;
public int addCounter(String name) {
Integer current = counter.get(name); int newValue = https://www.it610.com/article/++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;

【Java开发之多线程的基本概念与如何避坑】}
这段代码在多线程环境下,是不准确的。这是因为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发生的机率。
来源:小姐姐味道 作者:小姐姐养的狗

    推荐阅读