JDK8线程池BUG引发的思考(上)

简介: JDK8线程池BUG引发的思考(上)

引言


某一天逛网上帖子的时候,突然发现了下面这一篇文章,但是着实没有想到一篇文章能牵扯出这么多东西,这篇文章介绍的是由于使用了JDK的线程池引发的一个BUG,牵扯到了GC和方法内联的优化对于代码运行产生的影响,线程池BUG在JDK8中就已经存在但是直到JDK11才被修复,这里在原文的基础上补充相关的知识点,算是给自己做一个笔记。


知识点总结:



这里先说明一下这篇文章的相关知识点直接进行一个总结,如果读者对于相关内容十分熟悉的话这里也不浪费各位的时间,可以直接关闭本文了(哈哈)

  1. jdk并发线程设计中存在的BUG,关于Executors.newSingleThreadExecutor 的实现细节上的问题讨论。
  2. finalize() 终结器的介绍,以及终结器对于GC的影响,这里用《effective Java》中的第八条总结了一波。
  3. JVM中的JIT内联方法优化可能会导致对象的生命周期可能并不能坚持到一个栈帧出栈,这也导致了Executors.newSingleThreadExecutor中通过finalize()方式回收资源导致线程池提前回收的BUG。
  4. JDK官方网站的关于Executors.newSingleThreadExecutor().submit(runnable)方法会抛出异常的讨论(参考下方资料第四条),但是直到JDK11才进行修复。


参考资料


下面这些参考资料都是十分优质,花不多的时间就能有很大的收获,特别是R大的回答,简直就是移动的百科全书,赞。

Java 中, 为什么一个对象的实例方法在执行完成之前其对象可以被 GC 回收?(必读)

Can java finalize an object when it is still in scope?

Executors.newSingleThreadExecutor().submit(runnable) throws RejectedExecutionException

JVM Anatomy Quark #8: Local Variable Reachability


Jdk 的并发线程设计中存在的BUG



这里有点同情写Executors.newSingleThreadExecutor();这个方法的老哥了,网上的文章基本都要拿他写的代码来反复鞭尸(当然JDK官方错误使用finalize() 确实不应该),这里我也不客气也要来鞭尸一番,为了分析这些内容我们必须要了解源代码怎么写的。这里先给一下个人参考各种资料之后得出的结论:

  1. Executors.newSingleThreadExecutor();在Jdk1.8中存在较大的隐患,当线程调用执行完成的同时如果此时线程对象的this引用没有发挥作用的时候,此时JIT优化和方法内联会提前判定当前的对象已死,GC会立马将对象资源释放,导致偶发性的线程池调用失败抛出拒绝访问异常。
  2. 当出现多线程切换的时候GC线程会把没有进的this引用的对象提前进行回收,通过方法内联的方式探知某个对象在方法内的“生命周期”,所以很有可能线程池还在工作。
  3. 当对象仍存在于作用域(stack frame)时,finalize也可能会被执行,本质上由于JIT优化以及方法中的对象生命周期并不是直到方法执行结束才会结束,而是有可能提前结束生命周期。
  4. Executors.newSingleThreadExecutor的实现里通过finalize来自动关闭线程池的做法是有Bug的,在经过优化后可能会导致线程池的提前shutdown从而导致异常。

下面我们一步步来解释这个BUG的来源,以及相关的知识点,最后我们再讲讲如何规避这个问题。


环境


JDK版本:代码异常是在 HotSpot java8 (1.8.0_221) 模拟情况中出现的(实际上直到jdk11才被正式修复)。


问题介绍


下面我们从原文直接介绍一下这个线程池的BUG带来的奇怪现象。

问题:线上偶发线程池的问题,线程池执行带有返回结果的任务,但是发现被拒绝执行。


ava.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@a5acd19 rejected from java.util.concurrent.ThreadPoolExecutor@30890a38[Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0]


原因分析:线程池中的线程被提前回收,下面给出一段模拟线程池的操作代码,在模拟代码中虽然futureTask显然是在线程池里面,同时按照正常的理解思路线程池对象肯定是在栈帧中存活的,但是实际上对象却在方法执行的周期内直接被GC线程给回收了,导致了“拒绝访问”的BUG(也就是出现了线程池关了,内部的任务却还在执行的情况):


public class ThreadPoolTest {
    public static void main(String[] args) {
        final ThreadPoolTest threadPoolTest = new ThreadPoolTest();
        // 创建8个线程
        for (int i = 0; i < 8; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    while (true) {
                        // 获取一个任务
                        Future<String> future = threadPoolTest.submit();
                        try {
                            String s = future.get();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } catch (ExecutionException e) {
                            e.printStackTrace();
                        } catch (Error e) {
                            e.printStackTrace();
                        }
                    }
                }
            }).start();
        }
        //子线程不停gc,模拟偶发的gc
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    System.gc();
                }
            }
        }).start();
    }
    /**
     * 异步执行任务
     * @return
     */
    public Future<String> submit() {
        //关键点,通过Executors.newSingleThreadExecutor创建一个单线程的线程池
        // PS:注意是单线程的线程池
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        FutureTask<String> futureTask = new FutureTask(new Callable() {
            @Override
            public Object call() throws Exception {
                Thread.sleep(50);
                return System.currentTimeMillis() + "";
            }
        });
        // 执行异步任务
        executorService.execute(futureTask);
        return futureTask;
    }
}


个人的运行情况和原文不太一样,有时候是刚跑起来直接抛出异常,有时候在执行几秒之后才报错,所以这一段代码在不同电脑上呈现的效果是不太一样的,但是可以确定的是JDK的写法是存在BUG的。


JIT优化


对于上面的代码,我们要如何验证JIT编辑器提前结束对象生命周期?这里我们接着引申一下,这里摘录了Stackflow中的一篇文章,来验证一下JIT优化的导致对象提前结束生命周期的一个理解案例。这篇文章的老哥提了一个很有意思的问题,原文问的是Can java finalize an object when it is still in scope? 也就是当对象依然在栈帧里面对象会提前结束生命周期么?这里我们挑回答中给的一个测试代码进行分析。


public class FinalizeTest {
    @Override
    protected void finalize() {
        System.out.println(this + " was finalized!");
    }
    public static void main(String[] args) {
        FinalizeTest a = new FinalizeTest();
        System.out.println("Created " + a);
        for (int i = 0; i < 1_000_000_000; i++) {
            if (i % 1_000_000 == 0)
                System.gc();
        }
        // System.out.println(a + " was still alive.");
    }/*运行结果:
    不放开注释:
    Created FinalizeTest@c4437c4
    FinalizeTest@c4437c4 was finalized!
    放开注释:
    Created FinalizeTest@c4437c4
    com.zxd.interview.cocurrent.FinalizeTest@c4437c4 was still alive.
    */
}


在上面这一段代码中,如果把最后一行注释,发现在进行GC的时候,虽然A这时候应该还是存在于main的栈帧中,可以看到如果不放开注释出现了很奇怪的现象那就是对象a被提前终止生命周期了,这也就导致和前文一样的现象,对象在方法块内提前结束了自己的生命周期,或者换个角度说由于垃圾收集线程的切换,此时发现a已经没有任何this引用被释放掉内存。当然如果我们加上被注释这段代码的效果就比较符合预期了,对象a的生命周期被保住了直到整个程序运行完成,这里就引出了一个结论:当对象仍存在于作用域(stack frame)时,finalize也可能会被执行


那么为什么会出现上面的奇怪现象呢?在原文中讨论的是toString()方法的底层是否会延长对象的生命周期,其实这是一种和JIT优化对抗的处理方式,使用打印语句将a的生命周期延长到方法出栈,这样就不会出现for循环执行到一半对象a却提前“死掉”的情况了。在JIT的优化中,上面的代码中的对象A被认为是不可达对象所以被回收,这种优化和我们长久的编程习惯可能会背道而驰,作为编程人员来说我们总是希望对象的生命周期能坚持到方法完成,但是实际上JIT和方法内联会尽可能的回收不可达的对象,下面我们就来了解一下什么是方法内联。


内联优化


在结论中讲述了内联优化代码的情况,下面我们来看一下《深入理解JVM虚拟机》是如何介绍方法内联的,方法内联简单的理解就是我们常说的消灭方法的嵌套,尽量让代码“合并”到一个方法体中执行,这样做最直观的体现就是可以减少栈帧的入栈出栈操作,我们都知道虽然程序计数器不会内存溢出,但是虚拟机栈的大小是有限的,并且在JDK5之后每一个线程具备的虚拟机栈大小默认为1M,显然减少入栈和出栈的次数是一种“积少成多”的优化方式,也是能直观并且显著的提升效率的一种优化手段。

为了更好理解方法内联,这里我们举一个案例,下面的方法是有可能进行方法内联的:


public int add(int a, int b , int c, int d){
          return add(a, b) + add(c, d);
    }
    public int add(int a, int b){
        return a + b;
    }


值得注意的是只有使用invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法才会被方法内联,也就是说如果可以话我们还是尽量把方法设置为private static final,特别是静态方法可以直接内联到一个代码块。除此之外大部分的实例方法都是无法被内联的,因为他设计的是分派和多态的选择,并且由于java是一门面向对象的语言,最基本的方法函数就是虚方法,所以对虚方法的内联是一个难题。


小贴士:

非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不变的,这样的方法称为非虚方法。

虚方法:静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。


顺带一提是方法内联绝对不是在代码中完成的,其实仔细想想也是可以理解,如果在代码里面完成方法的合并那么原有的逻辑就乱套了,所以为了解决上面这一系列问题Java的虚拟机首先引入叫做类型继承关系分析(class Hierarchy Analysis CHA)技术,个人理解这种优化方式为“富贵险中求”,主要是分析某个类的继承树以及重写或者重写方法的信息,如果发现是非虚的方法,直接内联就可以了,但是如果是虚方法,则对于当前的方法进行检查,如果检查到“可能”只有一个版本尼玛就可以假设这一段代码就是它最终完成的样子,这种方法也被叫做“守护内联”,当然由于Java动态连接的特性还有代理等情况,所以这种守护内联的方式最终是要留下逃生门的,一旦这样的激进优化出现失败或者异常,则需要马上切回到纯解析的模式进行工作。


吐槽:这种优化方式有点像考试作弊,老师没有发现就能一直瞄一直抄,效率提升200%,但是一旦被老师发现,哼哼,成绩不仅全部作废,你还要单独安排到一个教室全部重考!所以作弊是富贵险中求呀。


当然这种激进优化一旦出问题并不是马上就放弃优化,这时候还有一个技术叫做“内联缓存”,内联缓存 大致的工作原理如下:

  1. 未发生方法调用,内联缓存为空。
  2. 第一次调用,记录方法接受者的版本信息到缓存中,后续的每次调用如果接受的版本都是一样的,这时候会使用单态内联缓存,通过缓存的方式调用要比不内联的虚方法调用多一次类型判断。
  3. 但是如果版本信息不一致,一样要退化成超多态的内联缓存形式,开销相当于查找虚方法表的方法分派。

以上就是方法内联干的一些事情,既然了解了方法内联和JIT优化


简单分析newSingleThreadExecutor


虽然我们通过一系列的手段排查发现了一个GC中隐藏的“漏洞”,但是我们可以发现这其实归根结底是JDK代码的BUG导致了这一系列奇怪的问题产生,下面我们回过头来简单分析一下这个BUG,下面是JDK 源代码:


/**
      * 创建一个执行器,它使用单个工作线程操作无界队列,
        并在需要时使用提供的 ThreadFactory 创建一个新线程。 
        与其他等效的 {@code newFixedThreadPool(1, threadFactory)} 不同,
        返回的执行程序保证不能重新配置以使用其他线程。
      *
      * @param threadFactory 创建新工厂时使用的工厂
      * 线程
      *
      * @return 新创建的单线程 Executor
      * @throws NullPointerException 如果 threadFactory 为空
      */
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }


这个方法的JavaDoc的描述如下(英语水平有限,直接把API文档拿来机翻了):

创建一个执行器,它使用单个工作线程操作无界队列,并在需要时使用提供的 ThreadFactory 创建一个新线程。 与其等效的 {@code newFixedThreadPool(1, threadFactory)} 不同,返回的实例并不能保证给其他的线程使用,其实从名字也可以看出,这里就是新建一个单线程的线程池。

这里可以看到FinalizableDelegatedExecutorService这个类重写了finalize方法,并且实际上内部调用的是一个包装器对象的终结方法,这样也就是一切奇怪现象的“罪魁祸首”了:


public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
static class FinalizableDelegatedExecutorService
        extends DelegatedExecutorService {
        FinalizableDelegatedExecutorService(ExecutorService executor) {
            super(executor);
        }
        // 重写了 finalize 方法
        protected void finalize() {
            super.shutdown();
        }
    }


关于finalize的相关细节会在下文进行总结,这里稍安毋躁,在代码中可以看到newSingleThreadExecutor返回的是一个包装类而不是本身,所以它是通过调用包装类的的顶级接口的super.shutdown();进行资源关闭的,同时super.shutdown();的动作是在终结方法里面完成,其实可以看到代码本身的意图是好的,让线程池在不再使用的情况下保证随着GC会进行回收。但是其实只要稍微了解finalize的同学就应该清楚,抛开JIT和方法内联不谈这种写法的代码本身执行结果就是“不确定”的,因为finalize执行取决于垃圾收集器的实现,同时他也不一定会调用(比如main线程刚执行完成就exits了),所以如果对象的生命周期和作用域控制不当就会在垃圾收集线程GC的时候出现this引用丢失提前回收问题(当然这里是多线程切换导致的GC)。

此时我们可以回顾一下之前原文中的测试代码,线程池在开启之后他看上去好像就没有在干活了(实际上内部有线程对象在执行任务),显然这里想要利用finalize()作为“安全网”进行线程资源的回收的手段有失偏颇,最后这个关于JDK的线程池BUG是在JDK11修复的,他的处理代码如下:


JUC  Executors.FinalizableDelegatedExecutorService
public void execute(Runnable command) {
    try {
        e.execute(command);
    } finally { reachabilityFence(this); }
}


提示:当然还有一种方法是在代码中手动执行一下关闭线程池,也可以规避JIT优化带来的奇怪现象。


如何规避?


如何解决上面的问题以及如何和JIT和方法内联对抗?以JDK9为界的两种方法(技巧),JDK官方给出这种解决办法也说明了目前情况下不会对于这种JIT优化做兜底处理,意思就是说不能让编译优化器去配合你的代码工作,而是要让你的代码可以符合预期行为,个人来看其实四个字:关我屁事。

  1. 在Java 9里只要加上一个reachabilityFence()调用就没事了

Reference.reachabilityFence(executor); // HERE


  1. JDK8以及之前的版本中则需要 手动调用的方式让对象不会因为线程切换this引用被GC误判为不可达:


executor.shutdown(); // HERE


其实通篇看下来发现最后好像有点实际技巧和理论的东西好像就这两个方法。NONONO,软件的领域有一句话说的好,叫做知其然知其所以然,如果我们以为的选择去知其然那么很有可能沦为“代码工具人”而不是一个会认真思考的程序员。

下面我们再挖掘一下终结方法的使用和细节,这些教条在《effective Java》这本神书里面介绍了,所以我们直接从书中的内容进行总结吧。

  1. 其实还有一种方式,就是使用try - final的方式进行处理,在学习基础的时候我们都知道final语句是必然会执行的,也可以保证this引用直到final执行完成才被释放引用。


补充:阿里巴巴开发建议


其实阿里巴巴的手册很早之前就禁止使用Executors去创建线程,现在看来这里其实隐藏着另一个陷阱,那就是SingleThreadPool重写了finalize方法可能因为JIT优化和方法内联而进坑里面,也算是不使用的一个理由吧。

下面是手册的介绍,这里直接贴过来了:


线程池不允许使用 Executors 去创建,而是通过ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

1) FixedThreadPool 和 SingleThreadPool :允许的请求队列长度为 Integer.MAX_VALUE ,可能会堆积大量的请求,从而导致 OOM 。

2) CachedThreadPool 和 ScheduledThreadPool :允许的创建线程数量为 Integer.MAX_VALUE ,可能会创建大量的线程,从而导致 OOM

(感兴趣的同学可以翻翻手册的“并发”部分,在靠前的几条就有介绍(说明翻车的人还挺多?))

相关文章
|
2月前
|
缓存 安全 Java
JDK8线程池BUG引发的思考
JDK8线程池BUG引发的思考
89 0
|
10月前
|
前端开发 关系型数据库 MySQL
用户重复注册分析-多线程事务中加锁引发的bug
用户重复注册分析-多线程事务中加锁引发的bug
120 0
|
10月前
|
关系型数据库 MySQL Java
多线程执行事务中再加锁导致的bug----------记一次线上问题优化
多线程执行事务中再加锁导致的bug----------记一次线上问题优化
62 0
|
11月前
|
缓存 安全 Java
JDK8线程池BUG引发的思考
这里先说明一下这篇文章的相关知识点直接进行一个总结,如果读者对于相关内容十分熟悉的话这里也不浪费各位的时间,可以直接关闭本文了(哈哈)
95 0
|
Oracle Java 关系型数据库
线程池中的一个 BUG,注意了!!
线程池中的一个 BUG,注意了!!
110 0
|
安全 Java 程序员
JDK8线程池BUG引发的思考(下)
JDK8线程池BUG引发的思考(下)
199 0
|
SQL 缓存 安全
Myabtis Interceptor 线程安全引发的 Bug
现在我们回到标题重点没有考虑Interceptor线程安全,导致断点调试时才会出现的bug晚上下班后,突然想到调试中遇到的org.apache.ibatis.executor.ExecutorException: Executor was closed.是啥情况?
206 0
Myabtis Interceptor 线程安全引发的 Bug
|
Java 应用服务中间件 容器
Tomcat是如何修正JDK原生线程池bug的?
为提高处理能力和并发度,Web容器一般会把处理请求的任务放到线程池,而JDK的原生线程池先天适合CPU密集型任务,于是Tomcat改造之。
117 0
Tomcat是如何修正JDK原生线程池bug的?
|
弹性计算 监控 Java
由于不知道Java线程池的bug,某程序员叕被祭天(下)
由于不知道Java线程池的bug,某程序员叕被祭天
89 0
由于不知道Java线程池的bug,某程序员叕被祭天(下)
|
缓存 Java 程序员
由于不知道Java线程池的bug,某程序员叕被祭天(上)
由于不知道Java线程池的bug,某程序员叕被祭天
167 0
由于不知道Java线程池的bug,某程序员叕被祭天(上)