Java多线程基本概念以及避坑指南

目录
  • 前言
  • 1. 多线程基本概念
    • 1.1 轻量级进程
  • 1.2 JMM
    • 1.3 Java中常见的线程同步方式
  • 2. 避坑指南
    • 2.1. 线程池打爆机器
    • 2.2. 锁要关闭
    • 2.3. wait要包两层
    • 2.4. 不要覆盖锁对象
    • 2.5. 处理循环中的异常
    • 2.6. HashMap正确用法
    • 2.7. 线程安全的保护范围
    • 2.8. volatile作用有限
    • 2.9. 日期处理要小心
    • 2.10. 不要在构造函数中启动线程
  • End

前言

多核的机器,现在已经非常常见了。即使是一块手机,也都配备了强劲的多核处理器。通过多进程和多线程的手段,就可以让多个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,到各种中间件,再到各种数据库连接池缓存等,每个地方都充斥着多线程的代码。

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

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

到此这篇关于Java多线程基本概念以及避坑指南的文章就介绍到这了,更多相关Java多线程概念及避坑内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java多线程学习笔记

    目录 多任务.多线程 程序.进程.线程 学着看jdk文档 线程的创建 1.继承Thread类 2.实现Runable接口 理解并发的场景 龟兔赛跑场景 实现callable接口 理解函数式接口 理解线程的状态 线程停止 线程休眠sleep 1.网路延迟 2.倒计时等 线程礼让yield 线程强制执行 观察线程状态 线程的优先级 守护线程 线程同步机制 1.synchronized 同步方法 2.同步块synchronized(Obj){} lock synchronized与lock 多任务.多

  • Java基础之多线程方法状态和创建方法

    目录 Java之线程的五大状态及其常用方法(六个状态还有timed_wating超时等待) 1.线程的五大状态及其转换 2.设置或获取多线程的线程名称的方法 3.线程休眠------sleep()方法 4.线程让步------yield()方法 5. 等待线程终止------join()方法 6. 线程停止 7. 线程等待------wait()方法 8. 线程唤醒-------notify()方法 9. notifyAll()方法 JAVA多线程有哪几种实现方式? 1. 继承Thread类 2

  • Java多线程之线程状态详解

    目录 线程状态 停止线程 线程休眠 模拟网络延迟(放大问题的发生性) 模拟计时 线程礼让 插队(线程强制执行) 线程状态观测 线程优先级 守护线程 总结 线程状态 五个状态:新生.就绪.运行.死亡.阻塞 停止线程 不推荐使用JDK提供的stop().destroy()方法[已弃用] 推荐线程自己停止 建议用一个标志位进行终止变量,到flag=false,则终止线程运行 public class StopDemo implements Runnable { // 设置一个标志位 boolean f

  • 一文彻底搞懂java多线程和线程池

    目录 什么是线程 一. Java实现线程的三种方式 1.1.继承Thread类 1.2.实现Runnable接口,并覆写run方法 二. Callable接口 2.1 Callable接口 2.2 Future接口 2.3 Future实现类是FutureTask. 三. Java线程池 3.1.背景 3.2.作用 3.3.应用范围 四. Java 线程池框架Executor 4.1.类图: 4.2 核心类ThreadPoolExecutor: 4.3 ThreadPoolExecutor逻辑结

  • Java多线程之并发编程的核心AQS详解

    目录 一.AQS简介 1.1.AOS概念 1.2.AQS的核心思想 1.3.AQS是自旋锁 1.4.AQS支持两种资源分享的方式 二.AQS原理 2.1.同步状态的管理 2.2.等待队列 2.3.CLH队列中的结点 2.4.队列定义 2.5.AQS底层的CAS机制 2.6.通过ReentrantLock理解AQS 三.AQS方法 3.1.用户需要自己重写的方法 3.2.AQS 提供的一系列模板方法 3.3.acquire(int)方法 3.4.release(int)方法 3.5.acquire

  • Java多线程基本概念以及避坑指南

    目录 前言 1. 多线程基本概念 1.1 轻量级进程 1.2 JMM 1.3 Java中常见的线程同步方式 2. 避坑指南 2.1. 线程池打爆机器 2.2. 锁要关闭 2.3. wait要包两层 2.4. 不要覆盖锁对象 2.5. 处理循环中的异常 2.6. HashMap正确用法 2.7. 线程安全的保护范围 2.8. volatile作用有限 2.9. 日期处理要小心 2.10. 不要在构造函数中启动线程 End 前言 多核的机器,现在已经非常常见了.即使是一块手机,也都配备了强劲的多核处

  • Linux下安装Python3.6及避坑指南

    Python3的安装 1.安装依赖环境 Python3在安装的过程中可能会用到各种依赖库,所以在正式安装Python3之前,需要将这些依赖库先行安装好. yum -y install zlib-devel bzip2-devel openssl-devel ncurses-devel sqlite-devel readline-devel tk-devel gdbm-devel db4-devel libpcap-devel xz-devel 2. 下载Python3源代码 下载Python3的

  • ant-design-vue 快速避坑指南(推荐)

    ant-design-vue是蚂蚁金服 Ant Design 官方唯一推荐的Vue版UI组件库,它其实是Ant Design的Vue实现,组件的风格与Ant Design保持同步,组件的html结构和css样式也保持一致. 用下来发现它的确称得上为数不多的完整的VUE组件库与开发方案集成项目. 本文主要目的是总结一些开发过程中比较耗时间去查找,文档中没有具体说明的常见问题,同时希望能给新上手此框架的同学提供一些参考作用. 1.Table对接后台返回数据 针对Table数据格式与后他接口返回数据格

  • .Net Core 2.2升级3.1的避坑指南(小结)

    写在前面 微软在更新.Net Core版本的时候,动作往往很大,使得每次更新版本的时候都得小心翼翼,坑实在是太多.往往是悄咪咪的移除了某项功能或者组件,或者不在支持XX方法,这就很花时间去找回需要的东西了,下面是个人在迁移.Net Core WebApi项目过程中遇到的问题汇总: 开始迁移 1. 修改*.csproj项目文件 <TargetFramework>netcoreapp2.2</TargetFramework> 修改为 <TargetFramework>net

  • .NET+PostgreSQL实践与避坑指南(推荐)

    简介 .NET+PostgreSQL(简称PG)这个组合我已经用了蛮长的一段时间,感觉还是挺不错的.不过大多数人说起.NET平台,还是会想起跟它"原汁原味"配套的Microsoft SQL Server(简称MSSQL),其实没有MSSQL也没有任何问题,甚至没有Windows Server都没问题,谁说用.NET就一定要上微软全家桶?这都什么年代了-- PG和MSSQL的具体比较我就不详细展开了,自行搜一下,这种比较分析文章很多.应该说两个RDBMS各有特色,MSSQL工具集庞大(大

  • Python学习之异常处理的避坑指南

    目录 finally与return的执行顺序 else与return的执行顺序 总结 最终想了想,还是把这个章节单独拎出来,虽然字数不多. 在代码中,存在return也应当执行finally: 存在return时,else是不执行的: 无return时,else正常执行: 如果发生异常,则else也不执行 finally 与 return 的执行顺序 示例代码如下: class Test(object):     def division(self, num1, num2):         t

  • GoFrame框架使用避坑指南和实践干货

    目录 gf gen dao 设置参数可不传 model作为结构体类型 使用with关联取值而不是join 不使用结构体批量添加数据 主程序如下: gomeGoods.MainImgs的定义: 插入数据 gf gen dao 生成dao层的脚手架工具很好用,我遇到的坑是这样的: 生成的dao文件和同事们的不一致,生成文件成功,但是对应的Columns是空的,虽然有这个方法,但是方法内没有值.我的版本比同事们的略高,我一直以为是这个原因,各种降级和同事保持一致的版本后还是不行. 最终发现:是配置文件

  • python函数默认参数使用避坑指南

    目录 引言 verify 炸弹 测试接口的数据 原因 改进方案 引言 阿刁是一个自动化测试用例,从一出生他就被赋予终生使命,去测试一个叫登录的过程是否合理.他一直就被关在一个小黑屋里面,从来也没有出去过,小黑屋里还被关着其他的同胞,他们身上都捆着两个小袋子. 小黑屋里很难受,他们都想跑出去,可怎么也跑不出去.Python 是他们的总司令,有一次,python 告诉他们,你们就不要想着跑出去了,你们已经够幸运了,只有 8 个人用这个屋子,别的屋子都挤着 30 多个人呢! “这里还有其他的屋子?”

  • go语言 nil使用避坑指南

    目录 引言 nil 默认值nil (重点记住) nil没有默认类型 不同类型的nil值占用的内存大小可能是不一样的 不同类型 nil 的指针是一样的 不同类型的 nil 是不能比较的 引言 今天笔试题遇到 var x string = nil ,问这个定义是否正确? 这里给出答案: cannot use nil as string value in variable declaration. 也就是说,string类型和nil八竿子打不着,要想判断字符串是否为空,可以使用str == "&quo

  • go语言中for range使用方法及避坑指南

    目录 前言 for range基本用法 for range 和 for的区别 for range容易踩的坑 for range和for性能比较 for range的底层原理 总结 参考资料 前言 for range语句是业务开发中编写频率很高的代码,其中会有一些常见的坑,看完这篇文章会让你少入坑. for range基本用法 range是Golang提供的一种迭代遍历手段,可操作的类型有数组.切片.string.map.channel等 1.遍历数组 myArray := [3]int{1, 2

随机推荐