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

37次阅读

共计 6600 个字符,预计需要花费 17 分钟才能阅读完成。

1. 多线程基本概念
1.1 轻量级过程
在 JVM 中,一个线程,其实是一个轻量级过程(LWP)。所谓的轻量级过程,其实是用户过程调用零碎内核,所提供的一套接口。实际上,java 培训它还要调用更加底层的内核线程(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 产生的机率。
起源:小姐姐滋味 作者:小姐姐养的狗

正文完
 0