站长网 语言 Java多线程 从基本概念到避坑指导

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

多核的机器,现在已经非常常见了。即使是一块手机,也都配备了强劲的多核处理器。通过多进程和多线程的手段,就可以让多个CPU同时工作,来加快任务的执行。 多线程,是编程中一个比较高级的话题。由于它涉及到共享资源的操作,所以在编码时非常容易出现问题

多核的机器,现在已经非常常见了。即使是一块手机,也都配备了强劲的多核处理器。通过多进程和多线程的手段,就可以让多个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提供了非常多的同步手段。
 
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,都登录不上,只能无奈的重启。大家发现,只要启动某个应用,过不了几分钟,就会出现这种情况。最终定位到了几行让人啼笑皆非的代码。
 
有位对多线程不太熟悉的同学,使用了线程池去异步处理消息。通常,我们都会把线程池作为类的静态变量,或者是成员变量。但是这位同学,却将它放在了方法内部。也就是说,每当有一个请求到来的时候,都会创建一个新的线程池。当请求量一增加,系统资源就被耗尽,最终造成整个机器的僵死。
 
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来替代它。

本文来自网络,不代表站长网立场,转载请注明出处:https://www.tzzz.com.cn/html/biancheng/yuyan/2021/1105/20295.html

作者: dawei

【声明】:站长网内容转载自互联网,其相关言论仅代表作者个人观点绝非权威,不代表本站立场。如您发现内容存在版权问题,请提交相关链接至邮箱:bqsm@foxmail.com,我们将及时予以处理。
联系我们

联系我们

0577-28828765

在线咨询: QQ交谈

邮箱: xwei067@foxmail.com

工作时间:周一至周五,9:00-17:30,节假日休息

返回顶部