0%

强引用(StrongReference)

正常的赋值语句生成的引用,只要强引用存在,与GC Roots对象有引用链,就不会被垃圾回收。

软引用(SoftReference)

虚拟机内存不够用时才会尝试回收软引用链接的对象,此次回收后还是不够用,才会抛出内存溢出的异常。

弱引用(WeakReference)

发生垃圾回收时,不论当前内存是否足够,都会回收弱引用链接的对象。

弱引用应用场景

官方文档:

假设垃圾收集器在某个时间点决定一个对象是弱可达的(weakly reachable)(也就是说当前指向它的全都是弱引用),这时垃圾收集器会清除所有指向该对象的弱引用,然后垃圾收集器会把这个弱可达对象标记为可终结(finalizable)的,这样它们随后就会被回收。与此同时或稍后,垃圾收集器会把那些刚清除的弱引用放入创建弱引用对象时所登记到的引用队列(Reference Queue)中。

实际上,WeakReference类有两个构造函数:

  • WeakReference(T referent) //创建一个指向给定对象的弱引用
  • WeakReference(T referent, ReferenceQueue<? super T> q) //创建一个指向给定对象并且登记到给定引用队列的弱引用

我们可以看到第二个构造方法中提供了一个ReferenceQueue类型的参数,通过提供这个参数,我们便把创建的弱引用对象注册到了一个引用队列上,这样当它被垃圾回收器清除时,就会把它送入这个引用队列中,我们便可以对这些被清除的弱引用对象进行统一管理。

参考:WeakReference才是LeakCanary真正的核心

虚引用(PhantomReference)

在虚引用链接的对象被垃圾回收后,虚引用会受到虚拟机的通知,并不能通过虚引用直接获得对象的实例,虚引用也不会影响对象的生命周期。

虚引用应用场景

DirectByteBuffer中对堆外内存的释放,通过虚引用监听DirectByteBuffer对象被GC回收后,再调用释放堆外内存的方法。

静态编译和动态编译分别是什么?

动态编译(dynamic compilation),指的是“在运行时进行编译”;

与之相对的是事前编译(ahead-of-time compilation,简称AOT),也叫静态编译(static compilation),程序运行前就把代码全部翻译成机器码

参考:

JIT编译是什么?

JIT编译(just-in-time compilation)狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT编译是动态编译的一种特例。

JIT编译一词后来被泛化,时常与动态编译等价;但要注意宽泛与狭义的JIT编译所指的区别。

JIT编译,全称 just-in-time compilation,按照其原始的、严格的定义,是每当一部分代码准备要第一次执行的时候,将这部分代码编译,然后跳进编译好的代码里执行。这样,所有执行过的代码都必然会被编译过。早期的JIT编译系统对同一个块代码只会编译一次。JIT编译的单元也可以选择是方法/函数级别,或者别的,例如trace。

参考:

解释执行是什么意思?

解释器:只在执行程序时,才一条一条把字节码解释成机器语言给计算机来执行

Java需要将字节码逐条翻译成对应的机器指令并且执行,这就是传统的JVM的解释器的功能,正是由于解释器逐条翻译并执行这个过程的效率低,引入了JIT即时编译技术。

为什么要遇到热点代码才要编译字节码为机器码?为什么不把所有的字节码都编译为机器码?

因为有些代码在将来只执行一次也有可能不执行,全部编译占用空间也浪费时间。

JIT编译器有什么好处?

在运行时编译,可以实时的获取运行时的信息,更好的做编译优化;静态编译是无法做这种优化的。

A JIT compiler runs after the program has started and compiles the code (usually bytecode or some kind of VM instructions) on the fly (or just-in-time, as it’s called) into a form that’s usually faster, typically the host CPU’s native instruction set. A JIT has access to dynamic runtime information whereas a standard compiler doesn’t and can make better optimizations like inlining functions that are used frequently.

为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?

解释器与编译器两者各有优势。

解释器:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。

编译器:在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

两者的协作:在程序运行环境中内存资源限制较大时,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。当通过编译器优化时,发现并没有起到优化作用,,可以通过逆优化退回到解释状态继续执行。

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。

解释器的执行,抽象的看是这样的:输入的代码 -> [ 解释器 解释执行 ] -> 执行结果

而要JIT编译然后再执行的话,抽象的看则是:输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果

说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”

1、只被调用一次,例如类的构造器(class initializer,())

2、没有循环

对只执行一次的代码做JIT编译再执行,可以说是得不偿失。对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。只有对频繁执行的代码,JIT编译才能保证有正面的收益。

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

参考:

HotSpot虚拟机为什么叫Hotspot?

通常,我们不必把所有的Java方法都编译成机器码,只需要把调用最频繁,占据CPU时间最长的方法找出来将其编译成机器码。这种调用最频繁的Java方法就是我们常说的热点方法(Hotspot,说不定这个虚拟机的名字就是从这里来的)。

HotSpot VM得名于它得混合模式执行引擎:这个执行引擎包括解释器和自适应编译器(adaptive compiler)。默认配置下,一开始所有Java方法都由解释器执行。解释器记录着每个方法得调用次数和循环次数,并以这两个数值为指标去判断一个方法的“热度”。显然,HotSpot VM是以“方法”为单位来寻找热点代码。等到一个方法足够“热”的时候,HotSpot VM就会启动对该方法的编译。这种在所有执行过的代码里只寻找一部分来编译的做法,就叫做自适应编译(adaptive compilation)。

参考:

JIT编译与HotSpot虚拟机有什么关系?

首先,如果一段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。

当然,如果一段代码频繁的调用方法,或是一个循环,也就是这段代码被多次执行,那么编译就非常值得了。因此,编译器具有的这种权衡能力会首先执行解释后的代码,然后再去分辨哪些方法会被频繁调用来保证其本身的编译。

在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,本文中简称JIT编译器)。

参考:

指针碰撞和空闲列表

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump thePointer)。

如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(FreeList)。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

参考:

对象创建的过程

  • 当虚拟机收到new指令后,检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化过。如果没有,必须先执行类加载过程。
  • 在类加载完成后可以确定对象分配所需要的空间。如果Java堆中内存是绝对规整的,用过的内存放一边,空闲的内存放另一边,中间放着一个指针作为分界点的指示器,那分配内存就只是把指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为”指针碰撞”。如果Java堆中内存不是规整的,空闲内存与使用过的内存是相互交错的,虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找出足够的空间分配给对象实例,并更新列表上的记录,这种分配方式称为”空闲列表”。采用哪种分配方式通常由虚拟机的垃圾收集器是否带有压缩整理功能决定。
  • 划分可用空间时,还需考虑为对象实例分配空间时是否是线程安全的。要保证线程安全,有两种方案。一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。另一种是把内存分配的动作按照线程划分在不同空间中进行,每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer , TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
  • 内存分配完成后,虚拟机对分配到的内存空间都初始化为零值(不包括对象头),保证对象的实例字段在Java代码中可以不赋初始值就可以直接使用。
  • 虚拟机将对象的信息放入对象的对象头中。
  • 执行构造函数

参考:

TLAB全称

Thread Local Allocation Buffer

线程本地分配缓存

TLAB作用

线程私有的内存分配区域,默认占Eden区1%,是为了避免多线程间在堆上分配内存时发生指针碰撞而降低性能。

Java中很多对象都是小对象,并且即用即丢,小对象分配在TLAB上,没有锁的开销,效率比分配在堆上要高。

Java对象分配的过程

  1. 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
  2. 如果tlab_top + size <= tlab_end,则在在TLAB上直接分配对象并增加tlab_top 的值,如果现有的TLAB不足以存放当前对象则3.
  3. 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
  4. 在Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,则5.
  5. 执行一次Young GC(minor collection)。
  6. 经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代。

对象不在堆上分配主要的原因还是堆是共享的,在堆上分配有锁的开销。无论是TLAB还是栈都是线程私有的,私有即避免了竞争(当然也可能产生额外的问题例如可见性问题),这是典型的用空间换效率的做法。

参考资料

常见的垃圾回收器有哪些?

  1. 串行(Serial)回收器是单线程的一个回收器,简单、易实现、效率高。

  2. 并行(ParNew)回收器是Serial的多线程版,可以充分的利用CPU资源,减少回收的时间。

  3. 吞吐量优先(Parallel Scavenge)回收器,侧重于吞吐量的控制。

  4. 并发标记清除(CMS,Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标的回收器,该回收器是基于“标记-清除”算法实现的。

  5. G1(Garbage First)收集器是一款在server端运行的垃圾收集器,专门针对于拥有多核处理器和大内存的机器,在JDK 7u4版本发行时被正式推出,在JDK9中更被指定为官方GC收集器。它满足高吞吐量的同时满足GC停顿的时间尽可能短。

参考《深入理解Java虚拟机(第2版)》 76页 3.5 垃圾收集器。

Serial收集器

新生代收集器,单线程收集,收集时Stop the world(暂停所有线程),采取复制算法

客户端新生代默认收集器,因为用户桌面应用场景中,分配给虚拟机的内存不会很大,停顿时间较短,只要不是频繁发生,可以接受,这样就发挥出Serial收集器简单而高效的优点。

Serial Old收集器

老生代收集器,单线程收集,收集时Stop the world(暂停所有线程),采取标记整理算法

Parallel New收集器

新生代收集器,Serial收集器的多线程版本,多个GC线程同时进行回收

Parallel Scavenge收集器

新生代收集器,采用复制算法多线程收集

目标是达到一个可控的吞吐量,吞吐量优先的收集器

吞吐量=程序运行时间/(程序运行时间+垃圾回收时间)

适合后台运算不需要太多实时交互响应的场景

适合对吞吐量和CPU资源很在意,对交互响应不敏感的的场景,例如后台服务,可以尽可能的多执行任务

可以开启自适应调解策略,让虚拟机根据系统运行情况动态调节内存分配的参数(如新生代大小、Eden与Survivor比例、晋升到老年代的年龄阈值等),以达到最佳的吞吐量,这是与Parallel New的重要区别。

还可以设置最大GC停顿时间和吞吐量给虚拟机设定优化的目标,由虚拟机自动调解最适合的参数来进行内存分配管理。

参考:

Parallel Old收集器

老年代收集器,Parallel Scavenge的老年代版本,使用多线程和标记整理,吞吐量优先

Concurrent Mark Sweep收集器

老年代收集器,以获得最短的GC停顿时间为目标的收集器,适合卡顿容忍度低的场景,例如有用户交互的情景(如Android手机交互)

收集过程为:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

初始标记和重新标记需要Stop the world(暂停所有线程)。初始标记仅标记跟GC Roots直接关联的对象,速度很快;并发标记就是进行完整的可达性分析(GC Roots Tracing);重新标记是为了修正并发标记期间进程继续运行而导致对象关系变动而重新标记这些变动的对象关系,运行时间比初始标记稍长,但是远比并发标记时间短,要扫描全堆。

缺点:

  1. 并发标记和并发清除所启用的多个GC线程占用了CPU资源,降低系统吞吐量。
  2. 无法处理并发清除期间程序继续运行而产生的浮动垃圾,不能等待老年代满了才收集,等到老年代占用的空间达到一个阈值,就要启用CMS清理,这样就预留了一定的空间来容纳并发清除期间产生的浮动垃圾,如果预留的空间无法满足程序继续运行的需要,则改用Serial Old执行清理。
  3. 标记清除算法会产生大量不连续的内存碎片,整理碎片只能串行执行,所以比较耗时,采用标记清除也是因为老年代的回收次数比较少,每次回收都标记整理性价比不高,可以在几次标记清除后再执行一次标记整理,调节一个可控的执行频率。

参考

为什么CMS只能用作老年代收集器,而不能应用在新生代的收集?

因为要并发清除,所以用的是标记清理算法,而标记清理算法会产生大量内存碎片,对新生代难以接受,新生代适合用复制算法,或者需要整理内存,以腾出连续的空间以供分配大对象。

因此新生代的收集器并未提供CMS版本。

为什么CMS不能用标记整理而要用标记清理?

因为老年代存活时间长,每次清理后,要整理的存活的对象太多了,比较耗时。

CMS主要关注低延迟,如果采用压缩算法,则涉及到要移动应用程序的存活对象,此时不停顿,是很难处理的,通常须要停顿下,移动存活对象,再让应用程序继续运行,但这样停顿时间变长,延迟变大。

参考:

Garbage First(G1)收集器

JVM内部知道,哪些region的对象最少(即:该区域最空),总是会优先收集这些region(因为对象少,内存相对较空,肯定快),这也是Garbage-First得名的由来,G即是Garbage的缩写, 1即First(第1)。

G1 根据存活对象的字节数统计每个区域的 活跃度liveness,然后根据期望停顿时间来确定该 CSet 的大小,并保证那些垃圾多(活跃度低)的区域会被优先回收,故此得名 垃圾优先。

设计目的

G1 能够在大内存、多处理器计算机上,保证 GC 暂停时间可控,并实现高吞吐量。

其最终目的是取代 CMS 成为服务端 GC 更好的解决方案:

  1. 采用 标记-整理 算法,可以避免使用细粒度的空闲列表进行分配。简化了收集器设计并消除了潜在的碎片问题。
  2. 提供可预测的GC暂停时间,无需牺牲很多吞吐量。

G1相对于CMS的优势

  1. CMS是标记清理,回收过后有大量内存碎片;G1从整体来看是基于标记-整理算法实现的,从局部(两=08u ym,ghvtbfrcRegion之间)上来看是基于“复制”算法实现的,避免了过多的内存碎片
  2. G1在逻辑上虽然也Eden、Survivor、Old区,但是都是以Region为单位,不需要扫描整个内存空间,只要扫描有存活对象的Region,减少了扫描的时间
  3. G1可以通过设置预期停顿时间( Pause Time) 来控制垃圾收集时间,在垃圾回收时尽量满足设置的停顿时间。G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。
  4. G1会在Young GC中使用,而CMS只能在Old区使用,因为年轻代需要用复制算法以腾出大量连续内存空间,CMS是标记清理算法会产生大量内存碎片

参考:

G1的适合场景

  1. 服务端多核CPU、JVM内存占用较大的应用。
  2. 应用在运行过程中会产生大量内存碎片、需要经常压缩空间。
  3. 想要更可控、可预期的GC停顿周期:防止高并发下应用的雪崩现象,实现高吞吐量。

一句话总结:实现高吞吐、没有内存碎片、收集时间可控。

G1收集器是server-style的垃圾收集器,适用于具有大内存的多处理器计算机。它极有可能满足垃圾回收(GC)暂停时间目标,同时实现高吞吐量。

对于打算从CMS或者ParallelOld收集器迁移过来的应用,按照官方 的建议,如果发现符合如下特征,可以考虑更换成G1收集器以追求更佳性能:

  1. 实时数据占用了超过半数的堆空间;
  2. 对象分配率或“晋升”的速度变化明显;
  3. 期望消除耗时较长的GC或停顿(超过0.5——1秒)。

G1内存划分

G1把内存分为大约2000个左右的一个个大小固定的小块,小块叫Region,每个Region被标记为分代标志,如Eden、Survivor、Old、Humongous,每个Region在物理内存中可以不连续

G1把内存划分为Region区域有什么好处?

  1. 其他收集器会在全堆做扫描,导致gc停顿时间会比较长,而G1只是整理特定几个region,不必扫描全堆
  2. 在物理,上不需要连续,则带来了额外的好处有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾;停顿时间可预测,用户可以指定收集操作在多长时间内完成
  3. G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩,避免了内存碎片。

G1收集器中大对象怎么分配

  1. 对象的大小<0.5个RegionSize直接存在新生代Eden Region区
  2. 对象的大小>=0.5个RegionSize且对象的大小<1个RegionSize,存到大对象区Humongous Region
  3. 对象的大小>=1个RegionSize存到连续的大对象区Humongous Region

参考:

G1预测停顿时间是怎么做到的?

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

G1为什么高效?

为了提高扫描根对象和标记的效率,G1使用了二个新的辅助存储结构:

  • Remembered Sets:简称RSets,用于根据每个region里的对象,是从哪指向过来的(即:谁引用了我),每个Region都有独立的RSets。(Other Region -> Self Region)
  • Collection Sets :简称CSets,记录了等待回收的Region集合,GC时这些Region中的对象会被回收(copied or moved)。

Rememberred Set是什么?

记录哪些分区引用了当前的Region。

使得垃圾回收器不需要扫描整个堆,就可以找到谁引用了当前分区的对象,扫描RSet就行了。

Young区到Old区的引用则不需要单独处理,因为Young区中的对象本身变化比较大,没必要浪费空间去记录下来。

  1. RSet:全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。
  2. Card: JVM将内存划分成了固定大小的Card。这里可以类比物理内存上page的概念。

每个Region初始化时,会初始化一个remembered set(已记忆集合),这个翻译有点拗口,以下简称RSet,该集合用来记录并跟踪其它Region指向该Region中对象的引用,每个Region默认按照512Kb划分成多个Card,所以RSet需要记录的东西应该是 xx Region的 xx Card。

参考:

参考资料

JDK8默认垃圾回收器是什么?

Parallel Scavenge + Parallel Old

Parallel Scavenge是吞吐量优先的垃圾收集器,采用复制算法,多线程收集。

Parallel Old是Parallel Scavenge的老年代版本,采用标记整理算法,多线程收集。

参考:

JDK9默认垃圾收集器是什么?

G1。

采用G1因为性能优越。

参考:

Android ART虚拟机垃圾回收机制是怎样的?

参考:

如何判定一个对象是否可以被垃圾回收?

引用计数

记录每个对象被引用数量,产生新引用计数加1,引用失效计数减1

优点:实现简单
缺点:无法解决环形依赖引用

可达性分析

一个对象到GC Roots对象没有引用链,表示此对象是没有地方使用,可以被回收。

可以作为GC Roots的对象

  1. 虚拟机栈中的引用的对象,即栈帧中的局部变量表中引用的对象
  2. 本地方法栈中引用的对象,即native方法中引用的对象
  3. 方法区中类的静态属性引用的对象
  4. 方法区中常量引用的对象

优点:

  • 分析更加精确,解决了环形引用的问题

缺点:

  • 实现复杂,分析时间长
  • 分析过程需要保证引用关系不能发生变化,需要GC停顿,即让所有线程的执行都暂停(Stop the world)

参考《深入理解Java虚拟机(第2版)》3.2 对象已死吗

GC停顿是什么?

对象的可达性分析需要枚举GC Roots节点对象,必须要保证对象之间的引用关系不能一直变化,必须要停顿所有线程的执行,这就是Stop the world,也是垃圾回收的性能瓶颈所在。暂停线程的工作,需要等到安全点才能暂停。

怎么让线程在安全点暂停?

主动式中断(Voluntary Suspension),GC需要中断线程时,不直接中断线程,仅仅给每个线程设置一个标志,各线程主动轮询这个标志,发现中断标志为true就自己中断挂起。

线程不执行代码时(处于睡眠、等待或阻塞),无法主动轮询响应中断标志,虚拟机如果等待其恢复运行时间可能太长,垃圾回收时应该怎么应对?

如果一段代码中引用关系不会变化,称为这一段为安全区域,线程执行到安全区域的代码时,会标记自己已经进入了安全区域;虚拟机发起垃圾回收时,就不用管已经位于安全区域的线程了。

线程离开安全区域时,要检查系统是否已经完成了GC Roots的枚举(或者是检查整个垃圾回收是否已结束),如果还没有完成,就必须要等待,直到收到可以安全离开安全区域的信号为止。

引用

垃圾回收算法有哪些?分别有什么优缺点 ?

垃圾回收算法主要有:

  1. 标记-清除(Mark-Sweep)
  2. 复制(Copying)
  3. 标记-整理(Mark-Compact)
  4. 分代收集

标记-清除(Mark-Sweep)

先标记出内存中所有需要回收的对象,再统一回收所有被标记的对象。标记过程就是对象到GC Roots对象的可达性分析。

缺点:

  1. 记和清除两个过程都不高效。
  2. 容易产生大量不连续的空间碎片,分配大对象的时候无法找到连续的内存空间时会再次触发垃圾回收。

复制(Copying)

将内存分为大小相等的两块,每次只使用其中一块,当这一块内存用完后,将还存活的对象复制到另外一块内存上,再把已使用过的那块内存一次清理掉。

优点:

  1. 每次对整半块的内存清理,实现简单
  2. 内存分配时也没有内存碎片的问题,整体上更高效

缺点:

  1. 有一半的内存空间闲置不用浪费了
  2. 对象存活率较高时,就要进行较多的复制操作,效率降低

标记-整理(Mark-Compact)

先标记出内存中所有存活的对象,再统一向内存的一端移动,最后直接清理掉边界以外的内存

优点:

  1. 不会产生不连续的内存碎片

缺点:

  1. 标记和整理的速度较慢

分代收集(Generational Collection)

根据对象的存活周期将堆内存划分为几块,这样可以针对不同的区域的特性使用最合适最高效的垃圾回收算法,一般把堆划分为新生代和老年代,默认新生代占堆的三分之一空间,老年代占堆的三分之二空间。

新生代又被划分为一块较大的Eden区域和两个较小的Survivor区域,每次使用Eden区和其中一个Survivor区。

在新生代中每次垃圾回收时都有大量的对象死去,只有少量对象存活,采用复制算法,当开始垃圾回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间,只要付出少量存活对象的复制成本就可以完成内存回收。在HotSpot虚拟机中Eden区和Survivor区大小比值默认为8:1,新生代只有10%的内存空间被浪费。当新生代空间不够时,需要老年代空间做分配担保,大对象直接存入老年代。

老年代中对象存活率高,必须使用标记清除或标记整理来进行回收。

参考《深入理解Java虚拟机(第2版)》 3.3 垃圾收集算法

分代收集有什么好处?

对传统的、基本的GC实现来说,由于它们在GC的整个工作过程中都要“stop-the-world”,如果能想办法缩短GC一次工作的时间长度就是件重要的事情。如果说收集整个GC堆耗时太长,那不如只收集其中的一部分?
于是就有好几种不同的划分(partition)GC堆的方式来实现部分收集,而分代式GC就是这其中的一个思路。

这个思路所基于的基本假设大家都很熟悉了:weak generational hypothesis——大部分对象的生命期很短(die young),而没有die young的对象则很可能会存活很长时间(live long)。

这是对过往的很多应用行为分析之后得出的一个假设。基于这个假设,如果让新创建的对象都在young gen里创建,然后频繁收集young gen,则大部分垃圾都能在young GC中被收集掉。由于young gen的大小配置通常只占整个GC堆的较小部分,而且较高的对象死亡率(或者说较低的对象存活率)让它非常适合使用copying算法来收集,这样就不但能降低单次GC的时间长度,还可以提高GC的工作效率。

参考:

基于分代收集回收算法,堆上的内存分配策略是怎样的?

参考《深入理解Java虚拟机(第2版)》 3.6 内存分配与回收策略

对象优先在新生代Eden区分配

当Eden区没有足够的空间分配时,虚拟机发起一次Minor GC。

大对象直接进入老年代

例如很长的字符串或很长的数组。

长期存活的对象进入老年代

每个对象都有一个年龄计数器,对象在Eden区出生,经过一次Minor GC仍然存活,并能被Survivor容纳,年龄就增加1岁,年龄增加到一定程度(默认为15),就会晋升到老年代。

动态对象年龄判定

Survivor空间中相同年龄的对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

因为Eden区不足时会触发Minor GC,如果Minor GC后新生代空间仍然不足,需要老年代做分配担保,将存活的对象移动到老年代,如果老年代也没空间了,就要进行Full GC了,清理整个堆(包括老年代和新生代)。

Minor GC 前检查老年代剩余空间是否大于新生代所有对象的大小总和,如果大于,说明进行Minor GC是安全的,因为最坏情况下新生代对象全部存活并且达到年龄上限,可以安全的移动到老年代;如果老年代剩余空间小于新生代所有对象大小总和,最坏情况下老年代容纳不了新生代所有对象,新的对象也没有办法分配在Eden区了,这个时候按道理就要触发Full GC了,清理整个堆(包括老年代和新生代)。

Minor GC、Full GC分别是什么?有什么区别?分别在什么时候触发?

  • Minor GC 回收新生代
  • Full GC 回收整个堆,包括新生代、老年代、元空间(Java8新增)

Eden区不足时会触发Minor GC,如果Minor GC后新生代空间仍然不足,需要老年代做分配担保,将存活的对象移动到老年代,如果老年代也没空间了,就要进行Full GC了,清理整个堆(包括老年代和新生代)。

如果一开始就能知道老年代空间不足,就不需要先Minor GC再Full GC,直接进行Full GC更省事,所以Minor GC前会判断老年代剩余空间是否大于新生代所有对象大小总和,如果大于,最坏情况下新生代对象全部存活,全部放到老年代还是可以放得下的,如果小于,最坏情况下肯定是放不下,但也有可能经过Minor GC后,剩余存活对象Survivor放不下但老年代可以放的下,这是不确定的,此时如果设置了允许分配担保失败,会检查老年代剩余空间是否大于历次新生代晋升到老年代的对象的平均总大小,如果大于说明Minor GC应该是安全,这样也不会频繁触发Full GC,如果小于就直接进行Full GC了。

参考:

JVM是如何避免Minor GC时扫描全堆的?

垃圾回收时要判断哪些对象可以被回收,要做可达性分析,跟GC Root有引用关系的对象是存活对象,分析引用关系的时候,是不知道一个类被谁引用的,但是反过来可以知道一个类引用了别的什么类。

这就带来了一个问题,年轻代的Minor GC很频繁,做可达性分析的时候,如果有老年代的对象引用了年轻代的对象,为了找出这个引用关系,你得去扫描整个老年代,一个个检查老年代的对象是否对年轻代的对象有引用,这样效率太低了,扫描了全堆。

如果能记录下老年代有哪些类引用了年轻代类,那就不用扫描整个老年代了。这就是卡表(Card Table)的作用。

参考:从实际案例聊聊Java应用的GC优化

Minor GC时做对象的可达性分析,如果有老年代对象引用新生代时,难道要扫描一遍整个老年代?

Minor GC时检查老年代中有没有引用新生代对象是通过检查卡表来完成的,老年代内存被划分为等长的卡页,每个卡页有一个编号,卡表中每一项代表某个卡页是否有对象引用新生代对象,这样Minor GC时就不用扫描整个老年代了,保证频繁进行Minor GC不会占用太多CPU时间,提高了CPU的吞吐量。

虚拟机在对老年代中的对象更新引用时,会加入写屏障,暂时中断写操作,检查老年代的对象是否引用了新生代的对象,如果是的话,便更新CardTable,标记这一块卡页为脏。

参考:


卡表(CardTable)是什么?

讲老年代的空间划分为大小为512B的若干张卡。

卡表是单字节的数组,数组中每个元素对应着一张卡,当发生老年代对象引用新生代时,虚拟机将该卡对应的卡片元素设置为适当的值。之后Minor GC时通过扫描卡表就可以很快识别出哪些卡中存在老年代指向新生代的引用。用空间换时间,避免了全堆扫描。

参考:

System.gc()是什么使用场景?

调用此方法只会建议虚拟机进行垃圾回收,并不会强制执行垃圾回收。

由于频繁垃圾回收会导致频繁的线程暂停导致性能下降,因此应当尽量让虚拟机自动管理垃圾回收,对不用的对象置为null。

所以System.gc()可以当作不存在,需要测试垃圾回收时可能需要,平时不应当主动依赖这个方法做垃圾回收。

参考:

垃圾回收的性能瓶颈在哪

性能瓶颈在于需要暂停所有的线程,影响程序正常运行。

根据程序特性合理配置各个区域大小,减少垃圾回收触发次数,可以提高性能,因为每次垃圾回收都要暂停所有线程的工作。

参考资料

Java运行时数据区域有哪些?

  1. 程序计数器
  2. 虚拟机栈
  3. 本地方法栈
  4. 方法区

其中程序计数器、虚拟机栈、本地方法栈是每个线程私有的,方法区和堆是线程间共享的。

程序计数器(Program Counter Register)

可以看作是当前线程所执行的字节码的行号指示器。

程序计数器的作用

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

为什么必须每个线程都要单独弄一个程序计数器?

因为多线程是通过轮流切换占用CPU时间片来实现的,线程数量大于CPU数量时,就会有线程处于等待状态,等到可以占用CPU时间片了,会恢复线程的执行,这时就必须要回到线程等待前的指令执行位置,以便接下来继续执行后面的指令,每个线程运行的指令又不一样,所以必须对每个线程都要保存当前执行的指令位置。

虚拟机栈(VM Stack)

虚拟机栈对应Java中的方法执行的内存模型。

栈中的每个元素称为栈帧,每个方法执行的时候都会创建一个栈帧,存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从开始执行到执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表(Local Variable Table)

存放方法参数和方法内的局部变量。

局部变量表中存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、long、float、double)、对象引用(reference类型,可能指向一个对象的起始地址的指针,也可能是指向一个对象的句柄)和returnAddress类型(指向一条字节码指令的地址)

局部变量表的基本存储单位是变量槽(Variable Slot),每个槽的大小是4个字节,64位的long和double会占用局部变量表的两个槽位(slot),其余数据类型占用一个槽位。

局部变量表所需的内存空间在编译阶段完全确定,因为数据类型的大小是确定的,方法运行期间不会改变局部变量表的大小。字节码中方法的Code属性的max_locals数据项中确定了需要分配的局部变量表的最大容量,在编译时写入。

局部变量表存储顺序:变量表从索引0开始,依次存放方法所属的对象引用(如果为静态方法则没有)、方法参数变量(按照顺序声明)、方法内局部变量(按照顺序声明)。对于byte、short、char这三种数据类型需要转换为int类型存储在局部变量表中。

一个代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class IntegerDemo {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d);
System.out.println(e == f);
System.out.println(c == (a + b));
System.out.println(c.equals(a + b));
System.out.println(g == a + b);
System.out.println(g.equals(a + b));
}
}

局部变量表为:

1
2
3
4
5
6
7
8
9
10
LocalVariableTable:
Start Length Slot Name Signature
0 183 0 args [Ljava/lang/String;
5 178 1 a Ljava/lang/Integer;
10 173 2 b Ljava/lang/Integer;
15 168 3 c Ljava/lang/Integer;
21 162 4 d Ljava/lang/Integer;
29 154 5 e Ljava/lang/Integer;
37 146 6 f Ljava/lang/Integer;
45 138 7 g Ljava/lang/Long;

没有局部变量表会怎么样?

参考《深入理解Java虚拟机(第2版)》190页 6.3.7 属性表集合。

LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间关系。
注:LocalVariableTable属性不是必须的,在javac编译时,可通过-g:none或-h:vars来取消或关闭这项信息。如果没有生成这项信息,最大的影响就是当别人引用这个方法时,所有的参数名称都将失去,IDE将会使用诸如arg0、arg1之类的占位符来代替原有的参数名,这对程序没什么影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获取参数值。

操作数栈(Operand Stack)

用于保存计算过程中的中间结果,作为计算的临时数据存储区。

大多数指令都要从这里弹出数据,执行运算后将结果再压回操作数栈。

操作数栈最大深度在编译时也是写入到字节码中方法的Code属性的max_stacks数据项中。

操作数栈的基本单位是4个字节,32位数据类型占用一个单位,64位数据类型占用两个单位,对于byte、short、char这三种数据类型需要转换为int类型再存入栈中。

操作数栈的特点

java的指令是基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,指令依赖操作数栈进行工作。

另外一种常用的指令集架构是基于寄存器的指令集。

基于栈的指令集 优点(反过来就是 基于寄存器的指令集 缺点):

  1. 可移植性强,直接依赖硬件寄存器将会受到硬件条件的约束
  2. 代码相对紧凑,因为指令没有操作数
  3. 编译器实现更简单,因为不用考虑空间分配,栈大小固定,编译时可知

基于栈的指令集 缺点(反过来就是 基于寄存器的指令集 优点):

  1. 速度稍慢,因为栈实现在内存中,频繁访问栈意味着频繁访问内存,访问内存是比访问寄存器慢很多的
  2. 指令数量多,因为访问数据频繁,入栈和出栈这两个指令就很多

参考《深入理解Java虚拟机(第2版)》270页 8.4.2 基于栈的指令集与基于寄存器的指令集

动态链接(Dynamic Linking)

每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

动态连接是一个将符号引用解析为直接引用的过程。当java虚拟机执行字节码时,如果它遇到一个操作码,这个操作码第一次使用一个指向另一个类的符号引用,那么虚拟机就必须解析这个符号引用。
在解析时,虚拟机执行两个基本任务:

  1. 查找被引用的类(如果必要的话就装载它)。
  2. 将符号引用替换为直接引用,这样当它以后再次遇到相同的引用时,它就可以立即使用这个直接引用,而不必花时间再次解析这个符号引用了。

参考:

  • 《深入理解Java虚拟机(第2版)》8.2.3 动态链接

方法返回地址(Return Address)

方法退出的过程实际上等同于把当前栈帧出栈。

因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令

参考《深入理解Java虚拟机(第2版)》 8.2.4 方法返回地址

虚拟机栈会有什么潜在的问题?

  • 某个线程中方法嵌套执行的太多了,超过虚拟机栈允许的最大深度,将会抛出StackOverflow(栈溢出)异常。
    • 一个典型的场景是递归方法,递归深度过大,会引起栈溢出,某些语言下可以采用尾递归优化。
  • 当线程不断增多,不停的申请虚拟机栈,内存可能不够用了,会引发OutOfMemoryError异常,即内存溢出。
    • 一个典型的场景是,程序中的同时运行的线程不停的增多。

本地方法栈(Native Method Stack)

虚拟机栈对应的是java方法的执行过程,本地方法栈对应native方法的执行过程。

堆(Heap)

创建一个对象实例便存储在堆,所有线程共享。

物理上可以不连续,逻辑上是连续的即可。

是虚拟机管理的内存区域最大的一块,是虚拟机垃圾回收的主要区域。

现代垃圾回收收集器基本都采用分代回收,堆被划分为新生代和老年代,新生代又分为Eden区、From Survivor区、To Survivor区。划分特定区域是为了更高效的进行垃圾回收。

对象都是在堆上分配的吗?

创建新对象实例也可能分配在TLAB和栈上。

对象不在堆上分配主要的原因还是堆是共享的,在堆上分配有锁的开销。无论是TLAB还是栈都是线程私有的,私有即避免了竞争(当然也可能产生额外的问题例如可见性问题),这是典型的用空间换效率的做法。

参考:

方法区(Method Area)

存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

各线程共享的区域。

方法区里的东西放在堆里不行吗?

因为方法区存储的类信息、常量等数据都是生命周期比较长的,要放在堆,也只能放在老年代,但可能生命周期比老年代的对象还要长。

故而单独开辟一个空间,单独管理,提高垃圾回收的效率。

方法区什么时候垃圾回收?

回收废弃常量和无用的类。

无用类三条判断方法:

  1. 堆中没有该类的实例
  2. 该类的类加载器已被回收
  3. 没有任何地方引用Class对象,也没有反射调用

这也是类卸载的判断。

参考《深入理解Java虚拟机》3.2.5 回收方法区 68页。

大量使用反射,动态代理,cglib等字节码框架都需要类卸载机制,保证方法区不溢出。

直接内存

《深入理解 Java 虚拟机 第三版》2.2.7 小节 关于 Java 直接内存的描述。

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解。  

在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。  

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置 -Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

为什么要使用线程池?

  1. 线程的创建和销毁是消耗性能的,如果频繁的开线程执行任务,可以考虑复用已启动的线程,避免无谓的性能开销,提高系统整体吞吐量
  2. 对任务异步调度进行了抽象,方便统一控制和测试

线程池的几个参数的作用分别是什么?

线程池是ThreadPoolExecutor类,构造函数的参数如下:

  1. int corePoolSize:核心线程数量,除非手动shutdown否则核心线程一直运行
  2. int maximumPoolSize:最大线程数量,最大线程数减去核心线程数的线程为非核心线程
  3. long keepAliveTime:非核心线程空闲时的存活时间
  4. TimeUnit unit:非核心线程空闲时的存活时间单位
  5. BlockingQueue workQueue:存放任务的工作队列,核心线程全都在执行任务时,没法执行新的任务了,新任务就存放在这里
  6. ThreadFactory threadFactory:线程创建的工厂,可以给线程设置名字,方便排查问题
  7. RejectedExecutionHandler handler:存放任务的阻塞队列存不下新的任务时的拒绝策略,做限流保护

任务拒绝策略有哪些?

AbortPolicy:抛出一个异常,默认的

DiscardPolicy:直接丢弃任务

DiscardOldestPolicy:丢弃队列里最老的任务,将当前这个任务继续提交给线程池)

CallerRunsPolicy:交给线程池调用所在的线程进行处理

线程池有哪些工作队列,分别什么场景使用?

主要三种:无界队列、有界队列、同步移交队列

无界队列

LinkedBlockingQueue,队列大小无限。

Executors.newFixedThreadPool 采用就是 LinkedBlockingQueue。

当某些任务耗时较长时,可能会导致队列中堆积存储大量任务,进而导致内存溢出。

有界队列

  • ArrayBlockingQueue
  • 有界的LinkedBlockingQueue
  • 支持设置优先级的PriorityBlockingQueue

有界队列的大小要和线程池大小配合,线程池比有界队列大时可以减少内存消耗、降低CPU使用率和上下文切换,但会限制吞吐量。

同步移交队

SynchronousQueue

其并不真正存储任务,当一个放入任务到队列中必须同时有一个线程正在等待取任务,无限数量的线程池可以使用队列,这样每次提交任务都会立刻执行。

LinkedTransferQueue

LinkedTransferQueue 是 SynchronousQueue 和 LinkedBlockingQueue 的合体,性能比 LinkedBlockingQueue 更高(没有锁操作),比 SynchronousQueue能存储更多的元素。

当 put 时,如果有等待的线程,就直接将元素 “交给” 等待者, 否则直接进入队列。

put 和 transfer 方法的区别是 put 是立即返回的, transfer 是阻塞等待消费者拿到数据才返回。transfer方法和 SynchronousQueue的 put 方法类似。

往线程池里提交一个任务会发生什么?

线程池的核心接口是Executor,里面只有一个execute(Runnable)方法,ExecutorService接口继承了Executor,提供了submit、shutdown、invokeAll、invokeyAny等方法。

提交任务有execute和submit两种方式,execute只能提交无返回值的Runnable,submit可以提交有返回值的Callable和无返回值的Runnable,submit方法会新建一个FutureTask封装传入的Callback和Runnable,Runnable会被适配为Callback对象,FutureTask是集成Runnable的,最终还是执行execute(Runnable),FutureTask内部只处理Callable

execute(Runnable)里的逻辑:

如果运行的线程数少于corePoolSize,则创建新线程执行任务。

如果运行的线程数大于corePoolSize,则将新任务加入工作队列,而不添加新的线程,核心线程执行完一个任务就从工作队列中取任务继续执行。

如果队列已满无法再加入,则创建新的线程执行,一直到线程总数超过maximumPoolSize时,对新任务执行拒绝策略。

execute里addWorker(null, false);,传递空任务,代表新建线程,不为空表示新建线程并添加第一个任务

submit任务后,任务内抛出的异常会拦截,通过Future获取异常对象,通过execute执行的任务,抛出的异常原封不动的向上抛。

线程池的大小应该设置多大?

任务分三类:

  1. CPU计算密集型
  2. IO密集型
  3. 计算和IO混合型

CPU计算密集型任务大部分时间用来做计算逻辑,消耗CPU资源,这种任务同时执行的数量应该跟CPU数量相等。因为任务同时执行的数量少于CPU数量的话,明明有CPU空闲,任务却得不到执行,就浪费资源了,系统整体吞吐量低;任务同时执行的数量多于CPU数量的话,由于所有的CPU都满载了,要让CPU分时间片给各个任务才能保证多出的人得到执行,而切换任务是有成本的,要保存恢复任务(线程)的上下文环境。

CPU密集型任务的线程池大小可以设置为CPU数量 + 1,额外多分配一个线程是因为其他线程偶尔会因为故障或其他原因暂停运行了,额外的一个线程可以确保CPU有任务执行不会被浪费。

IO密集型任务,多数线程处于阻塞状态等待IO完成,让出CPU,不消耗CPU资源,故而应该让线程池多处理一些任务。线程池大小一般设置为 2 * CPU数量 + 1。

假设有一个任务,计算需要C毫秒,IO操作需要等待W毫秒,整个任务的耗时就是C+W,一个CPU执行这个任务,CPU利用率U=C/(C+W),很明显0 <= U <=1,也就是说一个CPU一个线程的情况下该线程的CPU利用为U,如果想要使得CPU利用率为1,那就多开几个线程,让几个线程的CPU利用率加起来等于1就完事了,那么总共就需要 1/U=(C+W)/C=1+W/C 个线程,1个CPU利用率100%需要1+W/C个线程,那么N个CPU利用率要100%就再乘以N就好了,所以总线程数就是N*(1+W/C)

CPU计算密集型,可以认为没有IO操作,W=0,那么公式计算就会得到N

IO密集型,C应该比较小,W比较大,那么W/C就会大于1,代入公式得到总线程数至少是2N,但是线程数也不能很大,每创建一个线程都会占用内存空间,线程数量过大会内存溢出,还要考虑硬盘和网络带宽等IO资源的限制,线程搞多了IO处理不了也没有意义,只会浪费内存。IO密集型线程池大小一般就设为2N+1,加1也是为了防止有线程因为某种原因故障或暂停了,额外的这个线程确保CPU不会浪费。

线程等待的时间越长,需要越多的线程数;线程计算的时间越长,需要的线程数越少。

任务的等待时间和计算时间可以通过基准测试工具测试出来,求一个近似值,再代入公式,即可估算出线程池大小。如果实际不同种类的任务等待时间和计算时间差异较大,只能用一个线程池的情况下,那就取平均值。

也不是线程越多、CPU越多就可以无限提高运算速度,这是有上限的,可以用Amdahl(阿姆达尔)定律来衡量处理器并行运算之后效率的提升能力和上限,加速比 = 并行前系统耗时 / 并行后的系统耗时,加速比可以看作并行后提升了多少倍的执行速度。

并行前系统耗时可以分为两个部分,一个是只能串行执行的部分,一个是可以并行执行的部分,设p为可以并行执行的部分的比例,必须串行执行的部分的比例就是1-p,设cpu数量(或线程数)为n,可并行部分执行的时间就是p/n,并行后的执行时间就是 1-p+p/n,并行前执行时间就是1了,加速比即1/(1-p+p/n)

也可以设f为必须串行执行的部分的比例,可以并行执行的部分的比例就是1-f,设cpu数量(或线程数)为n,可并行部分执行的时间就是(1-f)/n,并行后的执行时间就是 f+(1-f)/n,加速比为1/(f+(1-f)/n)

必须串行执行的部分越大,加速比就越小,这就是多核(多线程)加速的上限值。

线程池的非核心线程什么时候会被释放?

非核心线程的保活借由BlockingQueue的带超时参数的poll()方法实现了,在keepAliveTime时间内没有从队列取到任务,就一直阻塞当前线程。

核心线程为什么一直可以运行,如何保证不销毁的?

线程的运行没有停止就不会消耗。

提交任务的执行路径:execute() -> addWorker() -> Worker.run() -> runWorker() -> getTask()

核心线程会在getTask()中阻塞的从工作队列中获取任务,即如果工作队列中没有任务,就一直阻塞当前线程,非核心线程从队列取不到任务就立刻返回不阻塞线程,线程逻辑就会走完。

通过判断当前线程池线程总数是否大于核心线程数来判断当前线程是否应该当做核心线程。

线程池有哪些坑?

要注意ThreadLocal的value内存泄漏问题,要及时remove。

自定义线程池的场景

追溯调用线程池之前的堆栈,防止线程里报错不知道外面哪里调用的。

ForkJoinPool解决了什么问题?

任务窃取机制,避免了取任务的时候多线程竞争一个队列,减少线程间竞争的等待开销,所以更快。

什么是happens-before原则?

前一个操作的结果对后续操作是可见的

为什么需要happens-before原则?

虚拟机会做编译优化,进行指令重排序,以提高CPU利用率,防止CPU出现空等待;但有些情况是不能重排序的,否则并发下会出问题,所以就要规定这些情况,happens-before原则就是这个规定.

典型场景?

比如写一个单例,new一个对象,指令执行的步骤是:

  1. 给对象分配内存
  2. 对象初始化
  3. 将对象内存地址关联

指令重排序,可能会导致对象还没初始化,就会已经被使用了,所以需要volatile来禁止指令重排序

有哪些happens-before原则?

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

如何验证指令发生了重排序?

定义两个变量设置初始值,在两个线程中分别赋新的值,然后打印在赋值语句后面打印另外一个线程修改的变量。

执行很多次,如果发生了打印的都是变量的初始值,就是发生重排序了,违反直觉,但是编译器觉得两条语句没有依赖关系,允许指令重排序

CPU的指令流水线是什么?解决了什么问题?

一条指令可以分为多个步骤完成,每个步骤可以由不同的硬件功能单元来执行,这样就可以让多个功能单元并行执行指令的不同步骤,不需要等待一个指令执行完再执行执行下一个指令,流水线可以让指令的一个步骤完成了就可以执行下一个指令的同一个步骤

CPU指令流水线存在什么问题?

如果指令之间存在数据相关性,导致前一条指令执行完后才能执行后一条指令,指令重叠就会发生冲突,无法并行执行,后面的指令只能等待,这个时候就可以把数据无关的指令重排序,以防止流水线停顿。


CPU指令流水线什么情况下会需要指令重排序?

一条指令可以分为多个步骤完成,如下:

  1. 取指 IF
  2. 译码和取寄存器操作数 ID
  3. 执行或者有效地址计算 EX
  4. 存储器访问 MEM
  5. 写回 WB

CPU在工作时,需要将上述指令分为多个步骤依次执行

为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:

  1. LW指令 表示 load,其中LW R1,b表示把b的值加载到寄存器R1中
  2. LW R2,c 表示把c的值加载到寄存器R2中
  3. ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
  4. SW 表示 store 即将 R3寄存器的值保持到变量a中
  5. LW R4,e 表示把e的值加载到寄存器R4中
  6. LW R5,f 表示把f的值加载到寄存器R5中
  7. SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
  8. SW d,R6 表示将R6寄存器的值保持到变量d中

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,e 和 LW R5,f 移动到前面执行,毕竟LW R4,e 和 LW R5,f执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响

正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。