Java虚拟机
1. Java虚拟机运行时的内存区域
Java虚拟机在执行Java程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK1.8和之前的版本略有不同。
JDK1.8之前:
JDK1.8
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
2. 什么是程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。用于记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空),字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
因此,程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行,选择,循环,异常处理等
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了
程序计数器是唯一以一个不会出现
OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环和异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候就能够知道该线程上次运行到哪里了
如果执行的是
native
方法,程序计数器记录的是undefined
地址,只有执行的是java代码时程序计数器记录的才是下一条指令的地址。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置
3. 什么是Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同,描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
Java内存可以粗糙的分为堆内存(Heap)和栈内存(Stack),其中栈内存就是虚拟机栈,或者说是虚拟机栈中局部变量表部分。(实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表,操作数栈,常量池引用,动态链接,方法出口信息)
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
Java 虚拟机栈会出现两种异常:StackOverFlowError
和OutOfMemoryError
。
StackOverFlowError
: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
异常。
OutOfMemoryError
: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError
异常。
Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
扩展:那么方法/函数如何调用?
Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:
- return 语句。
- 抛出异常。
不管哪种返回方式都会导致栈帧被弹出。
4. 什么是本地方法栈
本地方法栈所发挥的作用和虚拟机栈非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到Native方法服务。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息等。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError
和OutOfMemoryError
两种异常。
虚拟机栈和本地方法栈为什么是私有的
- 虚拟机栈:每个java方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在java虚拟机中入栈和出栈的过程。
- 本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机运行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机所使用到的
Native
方法服务。在HotSpot虚拟机中和Java虚拟机栈合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈都是私有的。
5. 什么是堆
Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆。从垃圾回收角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老生代,更细致一点有:**Eden
空间,From Survivor
,To Survivor
空间等**。进一步划分的目的是更好的回收内存,或者更快的分配内存。
上图中,eden
区,s0区,s1区都属于新生代,tentired
区属于老生代。大部分情况,对象都会首先在Eden
区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden
区->Survivor
区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认是15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
来设置。
堆的创建过程
Java堆初始化的入口为 Universe::initialize_heap()
方法,位于hotspot/src/share/vm/memory/universe.cpp
文件中。
首先根据 GC 方式确定 GC 策略与堆实现
执行流程如下:
- 如果 JVM 使用了并行收集器(
-XX:+UseParallelGC
),则将堆初始化为ParallelScavengeHeap
类型,即并行收集堆。 - 如果 JVM 使用了
G1
收集器(-XX:+UseG1GC
),则将堆初始化为G1CollectedHeap
类型,即 G1堆。同时设置 GC 策略为 G1 专用的G1CollectorPolicy
。 - 如果没有选择以上两种收集器,就继续检查是否使用了串行收集器(
-XX:+UseSerialGC
),如是,设置 GC 策略为MarkSweepPolicy
,即标记-清除。 - 再检查到如果使用了
CMS
收集器(-XX:+UseConcMarkSweepGC
),就根据是否启用自适应开关(-XX:+UseAdaptiveSizePolicy
),设置 GC 策略为自适应的ASConcurrentMarkSweepPolicy
,或者标准的ConcurrentMarkSweepPolicy
。 - 如果以上情况都没有配置,就采用默认的 GC 策略为
MarkSweepPolicy
。对于步骤 3~5 的所有情况,都会将堆初始化为GenCollectedHeap
类型,即分代收集堆。 - 调用各堆实现类对应的
initialize()
方法执行堆的初始化操作。
接着是构造 GC 策略和堆参数
以 CMS 标准 GC 策略 ConcurrentMarkSweepPolicy
为例:
ConcurrentMarkSweepPolicy::ConcurrentMarkSweepPolicy() {
initialize_all();
}
该 initialize_all()
方法由 ConcurrentMarkSweepPolicy
的父类 GenCollectorPolicy
来定义。
virtual void initialize_all() {
initialize_flags();
initialize_size_info();
initialize_generations();
}
可见,这个方法直接调用了另外三个以 initialize
为前缀的方法,它们分别完成特定的功能,下面按顺序来看:
initialize_flags() 方法:对齐与校验
对齐分为最大对齐和最小对齐。
这个方法首先调用 set_min_alignment()/set_max_alignment()
设置堆空间的对齐,来看一下最小对齐的定义,这里定义了分代堆空间的粒度,即216B = 64KB,也就是说各代必须至少按64KB对齐。
enum SomePublicConstants {
LogOfGenGrain = 16 ARM_ONLY(+1),
GenGrain = 1 << LogOfGenGrain
};
最大对齐则通过调用 compute_max_alignment()
方法来计算:
GenRemSet
是 JVM 中维护跨代引用的数据结构,通用名称为“记忆集合”(Remember Set)。对于常见的两分代堆而言,跨代引用就是老生代中存在指向新生代对象的引用,如果不预先维护的话,每次新生代 GC 都要去扫描老生代,非常麻烦。GenRemSet
的经典实现是卡表(CardTableRS),本质是字节数组,每个字节(即一张卡)对应老生代中一段连续的内存是否有跨代引用,如图所示。
卡表与最大对齐有什么关系呢?看以下方法。
uintx CardTableModRefBS::ct_max_alignment_constraint() {
return card_size * os::vm_page_size();
}
其中 card_size
为2的9次方 = 512,也就是每张卡对应 512B 的老生代内存。将它与 JVM 的普通页大小(一般是 4KB)相乘,就是最大对齐。如果JVM 启用了大内存分页,就继续用上面的计算结果与大页大小(一般是 2MB 或 4MB)取最小公倍数作为最大对齐。
size_t GenCollectorPolicy::compute_max_alignment() {
/**
* 卡标记阵列和旧版的偏移量阵列也都在os页面中提交。
* 确保它们完全装满(以避免部分页面问题),
* 例如:如果512字节堆对应于1字节条目,并且os页大小为4096,则最大堆大小应为512 * 4096 = 2MB对齐。
**/
size_t alignment = GenRemSet::max_alignment_constraint(rem_set_name());
/**
* 并行GC对各代进行自己的调整,以避免永久代需要大页面(某些平台上为256M)。
* 还应该更新其他收集器以进行自己的对齐,然后应删除对lcm()的使用。
**/
if (UseLargePages && !UseParallelGC) {
alignment = lcm(os::large_page_size(), alignment);
}
assert(alignment >= min_alignment(), "Must be");
return alignment;
}
堆空间对齐设置完了,接下来调用父类 CollectorPolicy
的同名方法,校验永久代大小(-XX:PermSize
、-XX:MaxPermSize
)以及一些其他配置。它的流程与本方法实现的校验新生代大小比较相似。
我们知道,新生代可以通过 -XX:NewSize
、-XX:MaxNewSize
与 -Xmn
三个参数来设定,设定 -Xmn
就相当于将前两个参数设为相同的值。接下来就将 NewSize
与 MaxNewSize
按64KB向下对齐,并确定它们是 64KB 的倍数。该方法实现基于宏定义,本质是位运算。
因为新生代由一个 Eden
区与两个 Survivor
区组成,所以 NewSize
不能小于 3 * 64 = 192KB
。另外,-XX:NewRatio
与 -XX:SurvivorRatio
都不能小于1,亦即老生代与新生代的比例不能小于1:1,Eden
区与 Survivor
区的比例不能小于1:2。
需要注意的是,GenCollectorPolicy
的子类TwoGenerationCollectorPolicy
中也有一个同名方法。它先调用了父类的方法,然后校验老生代和最大堆大小。
老生代大小 OldSize
对应JVM参数中的 -XX:OldSize
,最大堆大小 MaxHeapSize
自然对应 -Xmx
。这样,新生代、老生代和永久代的参数就都对齐并校验完毕了。
initialize_size_info()方法:设置堆与分代大小
与上面的 initialize_flags()
方法相似,这个方法在 CollectorPolicy
、GenCollectorPolicy
、TwoGenerationCollectorPolicy
中各有一个,分别负责真正设置整个堆、新生代和老生代的大小,并且同样是链式调用。它们的代码都很长,但功能单一,都是比较、对齐与赋值操作。
initialize_generations() 方法:生成分代管理器
虽然该方法的名字是“初始化分代”的意思,但它还不会执行真正的初始化动作,而是生成 GenerationSpec
实例,该实例内含有分代的描述信息(名称、大小等),在真正初始化分代时需要用到。这个方法由ConcurrentMarkSweepPolicy
自己实现。
- 首先调用
initialize_perm_generation()
方法生成永久代对应的PermanentGenerationSpec
(代码略)。 - 然后,检查是否符合
ParNewGeneration::in_use()
的条件,即启用并行新生代GC(-XX:+UseParNewGC
)并且GC线程数(-XX:ParallelGCThreads
)大于 0 - 如是,将新生代
GenerationSpec
的类型设置为ParNew
,否则设为DefNew
。 - 老生代
GenerationSpec
的类型则固定为ConcurrentMarkSweep
。
至此,初始化 GC 策略与堆参数的工作就完成了,下面主要是分配堆内存空间与分代的过程,还有一些其他的工作。
分配堆内存空间与分代
最后一次对齐
在创建分代之前,再将它们对齐一次,分代数量固定为2。新生代和老生代都是按最小粒度(即64KB)对齐,永久代则是按最大粒度对齐。
分配堆内存空间
主要作用的是通过 allocate()
方法,将一段连续的内存空间分配成ReservedSpace
,即预留空间。
该方法的大致执行流程如下:
- 确定当前的页大小。
- 根据新生代、老生代和永久代的各个
GenerationSpec
,将它们的最大内存大小累加到total_reserved
变量,作为申请内存的总量。 - 同时将
GenerationSpec
中的n_covered_regions
一同累加,该字段代表申请内存区域的数量,新生代、老生代都为1,永久代为2。 - 如果配置为大页模式,将申请内存的量向上对齐到页大小。
- 若启用了压缩普通对象指针(
-XX:+UseCompressedOops
),调用Universe::preferred_heap_base()
方法,以32位直接压缩的方式(UnscaledNarrowOop
)取得堆的基地址,并调用ReservedHeapSpace
的构造方法,申请内存。 - 如果上一步申请失败,说明比 4GB 大,就以零基地址压缩的方式(
ZeroBasedNarrowOop
)在更高的地址空间上取得堆的基地址并申请内存。 - 如果仍然申请失败,说明比 32GB 还大,就只能用普通的指针压缩方式(
HeapBasedNarrowOop
)取得堆的基地址并申请内存。 - 如果没有启用压缩普通对象指针,就直接用
ReservedHeapSpace
申请内存。最终都返回起始地址。
如果堆要在指定地址分配,亦即配置了共享空间或者指针压缩,就调用os::attempt_reserve_memory_at()
内存,否则就调用 os::reserve_memory()
方法申请内存。申请成功之后仍然要对齐,方法是先检查基地址是否对齐,如果没有,就直接释放掉分配的空间,将内存大小向上对齐之后,调用 os::reserve_memory_aligned()
重新申请一块对齐的空间。
调整堆大小并创建GenRemSet(记忆集合)
首先将堆空间封装成一个 MemRegion
对象,然后将前面的堆大小减去永久代中 Misc Code
与 Misc Data
两个区域的大小,就是堆的实际大小。最后,调用 GC 策略的 create_rem_set()
方法生成 GenRemSet
的实现,对于 CMS 而言就是 CardTableRS
,即卡表。
分代初始化
各个 GenerationSpec
n中都有一个 init()
方法来初始化它对应的分代,主体是一个 switch-case
结构。CMS 情况下的 ParNew
与 ConcurrentMarkSweep
分代实现:新生代对应的是 ParNewGeneration
实现,老生代对应的是 ConcurrentMarkSweepGeneration
实现。根据GenerationSpec
中记录的内存大小,就可以将之前申请的堆空间划分给各个代。
整个堆空间至此就基本创建完成了。
6. 什么是方法区
JDK1.8后取消了方法区,改用元空间
方法区和Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区还有一个别名叫Non-Heap
(非堆),目的是与Java堆区分开来(实际上方法区是堆的一个逻辑部分)
方法区也被称为永久代。
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非是数据进入方法区后就永久存在了。
JDK1.8的时候,方法区被彻底移除了,取而代之的是元空间,元空间使用的是直接内存。
可以通过以下参数设置元空间:
-XX:MetaspaceSize=N //设置Metaspace的初始(和最大大小)
-XX:MaxMetaspaceSize=N //设置Metaspace的最大大小
为什么要将永久代替换为元空间呢?
整个永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间采用的是直接内存,受本机可用内存的限制,并且永远不会得到java.lang.OutOfMemoryError
。可以通过上面的参数设置元空间最大大小,默认是unlimited
,意味着它只受系统内存的限制。也可以通过第一个参数标志元空间的初始大小,如果未指定此标志,则元空间将根据运行时的应用程序需求动态的重新调整大小。
此外还有其他很多底层的原因。
7. 什么是运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译器生成的各种字面量和符号引用)
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError
异常。
JDK1.7 及之后版本的JVM已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
8. 什么是直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError
异常出现。
JDK1.4 中新加入的NIO(New Input/Output)
类,引入了一种基于通道(Channel
) 与缓存区(Buffer
)的I/O
方式,它可以直接使用Native
函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的DirectByteBuffer
对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
9. 虚拟机中对象的创建过程
类加载检查
虚拟机遇到一条new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可以确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是 **”标记-清除”**,还是 **”标记-整理”**(也称作 **”标记-压缩”**),值得注意的是,复制算法内存也是规整的。
内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试:CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB:为每一个线程预先在Eden分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已经用尽时,再采用上述的CAS进行内存分配。
初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
设置对象头
初始化零值完成后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这下信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否启动偏向锁等(tag bits
信息),对象头会有不同的设置方式。
执行init方法
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚开始,init
方法还没有执行,所有的字段都还为零。所以一般来说,执行new
指令之后会接着执行init
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
10. 对象的内存布局
在Hotspot虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据、对齐填充。
Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行数据(哈希码、GC分代年龄、锁状态、标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也就是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅是起占位作用。因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
11. 对象的访问定位
建立对象就是为了访问对象,Java程序通过栈上得到reference
数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式由使用句柄和直接指针两种:
- 句柄:如果使用句柄的话,那么Java对堆中会划分出一块内存来作为句柄池,
reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体地址信息。
- 直接指针:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而
reference
中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是reference
中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference
本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
12. 8种基本类型的包装类和常量池
Java基本类型的包装类的大部分都实现了常量池技术,即Byte、Short、Integer、Long、Character、Boolean;这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。
两种浮点型的包装类Float,Double并没有实现常量池技术
Integer缓存源码
/**
*此方法将始终缓存-128 到 127(包括端点)范围内的值,并可以缓存此范围之外的其他值。
*/
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
应用场景
Integer i1 = 40; //Java在编译的时候会直接将代码封装成Integer i1 = Integer.valueOf(40);从而使用常量池中的对象
Integer i2 = new Integer(40); //这种情况下会创建新对象
Integer比较例子
Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
System.out.println("i1=i2 " + (i1 == i2)); //true
System.out.println("i1=i2+i3 " + (i1 == i2 + i3)); //true
System.out.println("i1=i4 " + (i1 == i4)); //false
System.out.println("i4=i5 " + (i4 == i5)); //false
System.out.println("i4=i5+i6 " + (i4 == i5 + i6)); //true
System.out.println("40=i5+i6 " + (40 == i5 + i6)); //true
语句i4 == i5 + i6
,因为+
这个操作符不适用于Integer
对象,首先i5
和i6
进行自动拆箱操作,进行数值相加,即i4 == 40
。然后Integer
对象无法与数值进行直接比较,所以i4
自动拆箱转为int
值40,最终这条语句转为40 == 40
进行数值比较。
13. 内存是如何分配和回收的
Java的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java自动内存管理最核心的功能是堆内存中对象的分配与回收。
Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代,在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分是为了更好的回收内存,或者更快地分配内存。
堆空间的基本结构:
上图所示的eden
区,s0("From")
区,s1("To")
区都属于新生代,tentired
区属于老年代。大部分情况,对象都会首先在Eden
区分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s1("To")
,并且对象的年龄还有加1(首次进入Survivor
后年龄初始化为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
来设置。经过这次GC后,Eden
区和From
区已经被清空。这个时候,From
和To
会交换它们的角色,也就是新的To
就是上次GC前的From
,新的From
就是上次GC前的To
。不管怎样,都会保证名为To
的Survivor
区域是空的。Minor GC
会一直重复这样的过程,直到To
区被填满,To
区被填满之后,会将所有对象移动到年老代中。
对象优先在eden区分配
目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
大多数情况下,对象在新生代中eden
区分配。当eden
区没有足够空间进行分配时,虚拟机将发起一次Minor GC
.
Minor GC 和 Full GC 有什么不同呢?
- 新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。
- 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。
分配担保机制:当Eden
区被分配满了之后,有新的对象需要分配内存,此时虚拟机将发起一次Minor Gc
,由于Eden
中的对象还有用,所以不被回收,当时其对象所占内存比较大,GC期间无法存入Survivor
空间,此时就会使用分配担保机制,将新生代的对象转移到老年代中去,如果老年代的空间足够,则不会发起Full GC
。后面分配的对象如果能够存在eden
区的话,还是会在eden
区分配内存。
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
XX:PretenureSizeThreshold
,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
原因:为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
长期存活的对象将进入老年期
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age
)计数器。
如果对象在eden
出生并经过第一次Minor Gc
后仍然能够存活,并且能被Survivor
容纳的话,将被移动到Survivor
空间中,并将对象年龄设为1。对象在Survivor
中每熬过一次Minor Gc
,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
来设置。
动态对象年龄判断
为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才进入老年代。如果Survivor
空间中所有年龄相同的对象大小总和大于Survivor
空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。
14. 如何判断对象已经死亡
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效时,计数器就减1;任何计数器为0的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,主要原因是它很难解决对象之间相互循环引用的问题。所谓对象之间的相互循环引用的问题。所谓对象之间的相互引用问题,指的是除了对象A和对象B相互引用着对方之外,这两个对象之间再无任何引用,但是它们因为互相引用对方,导致它们的引用的计数器都不为0,于是引用计数算法无法通知GC回收器回收它们。
可达性分析算法
这个算法的基本思想就是通过一系列的称为GC Roots的对象作为起点,从这些结点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。
GC Roots一般包含以下内容/JVM中哪些对象可以作为Root对象?:
- 虚拟机栈中局部变量表中引用的对象
- 本地方法栈中JNI引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
算法实现
HotSpot 首先需要枚举所有的 GC Roots 根节点,虚拟机栈的空间不大,遍历一次的时间或许可以接受,但是方法区的空间很可能就有数百兆,遍历一次需要很久。更加关键的是,当遍历所有 GC Roots 根节点时,我们需要暂停所有用户线程,因为我们需要一个此时此刻的”虚拟机快照”,找到此时此刻的可达性分析关系图。基于这种情况,HotSpot 实现了一种叫做 OopMap 的数据结构,存储 GC Roots 对象,同时并不是每个指令都会对 OopMap 进行修改,这样 OopMap 很庞大,这里 Hotspot 引入了安全点,safePoint,只会在Safe Point 处记录 GC Roots 信息。
OopMap: 虚拟机从外部记录下栈里那些 Reference 类型变量的类型信息,存成的一个映射表。
枚举根节点
GC Roots 主要存在全局性的引用(常量和类静态属性)和执行上下文(栈帧的本地变量表)中
可达性分析的执行对“引用一致性”非常地敏感,所以在枚举根节点时必须停顿所有线程。
引用一致性:指当 JVM 进行可达性分析时,必须保持当前的引用链是保持不变的,否则分析结果有可能会出现偏差。例如:在分析某个对象时得出其余 GC Roots 不可达的结论,但是在分析完成之前此对象在某一个地方被重新引用,但是 JVM 是不会重复进行分析的,显然结果会不正确。
为了缩短停顿时间,HotSpot使用一组 OopMap 的数据结构达到目的,在类加载完成时,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,并且存放在 OopMap 中,GC 在扫描时遍历 OopMap 就可以得到所有引用关系了。
安全点(Safe Points)
如果在程序中含有大量的指令,对象引用关系不断变化,每一次变化都会生成一条新的 OopMap,那么必然会导致 OopMap 也变得越来越庞大,遍历所使用的开销也会越来越大,此时使用安全点这种解决方案可以有效地解决这个问题。
安全点就是用来解决什么时候安全地进入GC的问题,安全点能够让所有线程进行中断挂起。
Safe Point的意义:保证所有线程当前的所有引用状态不会发生变化,所有线程要保证在安全点处中断。
JVM 进入 GC 阶段的两种线程中断方式(不是安全点的中断方式):
- 抢先式中断:
在 GC 发生时,让所有的线程进行中断,如果发现线程不是在安全点上,那么就恢复它,让它跑到安全点再进行中断。现在几乎没有虚拟机采用抢先式中断来暂停线程进行响应 GC 事件,突然地中断和恢复线程会导致程序出现很奇怪的现象。
- 主动式中断:
当 GC 操作需要中断线程时,不直接对线程进行操作,而是设置一个轮询标志,让线程执行时主动轮询这一个标志,轮询标志为 true 时主动中断;轮询标志的地方和安全点是重合的,另外还有创建对象需要分配内存的地方也会有轮询标志。这样保证了线程执行到JVM认为该线程可以停止的地方,而不会突然地中断线程了。
Safe Point通常存在的位置:
- 方法调用处
- 循环跳转处
- 异常跳转处
- 指令序列复用
Safe Points位置的选取特征:是否具有让程序长时间运行的特征。
安全区域(Safe Region)
如果程序不执行,有可能处于 Sleep 或 Blocked 状态,CPU没有分配给线程使用,此时线程肯定无法自己“跑”到安全点处再执行中断挂起,而 JVM 也不可能等待线程被唤醒,安全点这种方案无法满足 GC 的要求,所以此时需要采用安全区域方案。
安全区域是指在一段代码片段之中,引用关系不会发生变化,在此区域内的任何地方进行GC操作都是安全的,我们可以这样理解:安全区域就是一小块含有无穷无尽的安全点的区域。
执行过程:
在线程进入安全区域时,线程将主动标记自己进入了安全区域,此时 JVM 发起 GC 时就不用在乎这些在安全区域的线程了,当线程要离开安全区域时,它会检查系统是否已经完成了根节点枚举或者整个 GC 过程,如果已完成,则可以离开;若未完成,则需要等待允许离开安全区域的信号为止。
引用
无论是通过引用计数器判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判断对象的存活都与“引用”有关。
JDK1.2 之前,Java 中引用的定义很传统:如果reference
类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
强引用
我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝对不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError
错误,使程序异常终止,也不会回收具有强引用的对象来解决内存不足问题。
软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue
)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
使用SoftReference
类来创建软引用
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;//使对象只被软引用关联
弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue
)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
使用WeakReference
类来创建弱引用
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动,为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
虚引用和软引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue
)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到了引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory
)等问题的产生。
使用PhantomReference
来创建虚引用
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj,null);
obj = null;
不可达对象并非是“非死不可”
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程:可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize
方法。当对象没有覆盖finalize
方法,或者finalize
方法已经被虚拟机调用过时,虚拟机将这两种情况视为没必要执行。
finalize()
用于关闭外部资源,但是try-finally
等方式可以做得更好,并且该方式运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的finalize()
方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了finalize()
方法自救,后面回收时就不会再调用该方法。
如果对象被认为有必要执行finalize()
方法,那么这个方法会被放置在一个名为F-Queue
的队列之中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer
线程去执行。这里的”执行”也只是指虚拟机会触发这个方法,但并不承诺一定会执行。
finalize()
方法是对象逃脱死亡命运的最后一次机会,稍后GC会对F-Queue
中的对象进行第二次小规模的标记,如果对象在finalize()
中重新与引用链上的任何一个对象建立了关联,就会被移出”即将回收”集合,如果没有移出,那就会被真的回收。
15. 如何判断一个常量是废弃常量
运行时常量池主要回收的是废弃的常量。
假如在常量池中存在字符串 “abc”,如果当前没有任何String
对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池。
JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
16. 如何判断一个类是无用的类(方法区的回收)
方法区主要回收的是无用的类,判断一个常量是否是“废弃常量”比较简单,而判断一个类是否是“无用的类”,需要同时满足下面3个条件才能算是“无用的类”:
- 该类的所有实例都已经被回收,也就是java堆中不存在该类的任何实例
- 加载该类的
ClassLoader
已经被回收 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
17. 垃圾收集算法
标记-清除算法
该算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一空闲分块是否连续,若连续,则合并这两个分块。回收对象就是把对象作为分块,连接到被称为“空闲链表”的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小size
的块block
。如果它找到的块等于size
,会直接返回这个分块;如果找到的块大于size
,会将块分割成大小为size
与block - size
的两部分,返回大小为size
的分块,并把大小为block - size
的块返回给空闲链表。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到的。这种垃圾收集算法有两个明显的问题:
- 标记和清除的效率都不高
- 空间问题(标记清除后会产生大量不连续的碎片)
复制算法
为了解决效率问题,出现了“复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块内存使用完后,就将还存活的对象复制到另一块中去,然后把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
上面提到的虚拟机对新生代的回收中,Eden
和Survivor
就是使用了这种算法。
HotSpot 虚拟机的Eden
和Survivor
大小比例默认为8:1
,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块Survivor
就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
标记-整理算法
根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
优点:不会产生内存碎片
缺点:需要移动大量对象,处理效率比较低
分代收集算法
当前虚拟机的垃圾收集都是采用分代收集算法,这种算法根据对象存活周期的不同将内存划分为几块,一般将 java 堆划分为新生代和老年代,根据各个年代特点选择合适的垃圾收集算法。
在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只要付出少量对象的复制成本就可以完成每次垃圾收集。而在老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以必须选择“标记-清除”或者“标记-整理”算法进行垃圾收集。
延伸面试问题:为什么HotSpot要分为新生代和老年代。
根据上面对分代收集算法介绍回答
18. 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
原理
对于 GC 来说,当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是“可达的”,哪些对象是“不可达的”。当 GC 确定一些对象为“不可达”时,GC 就有责任回收这些内存空间。程序员可以手动执行 System.gc()
,通知 GC 运行,但是Java语言规范并不保证 GC 一定会执行。
以上是HotSpot虚拟机中的7个垃圾收集器,连线表示垃圾收集器可以配合使用。
Serial收集器
Serial
(串行)收集器是最基本、历史最悠久的垃圾收集器。这是一个单线程收集器,它的“单线程”的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程(“Stop The World”),直到它收集结束。
新生采用复制算法,老年代采用标记-整理算法。
虚拟机的设计者们当然知道Stop The World
带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是Serial
收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial
收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial
收集器对于运行在Client
模式下的虚拟机来说是个不错的选择。
JVM配置参数为:-XX:+UseSerialGC
,使用该配置参数,新生代和老年代均使用串行垃圾回收器,其中老年代为基于标记压缩算法实现的Serial Old。
ParNew收集器
ParNew
收集器其实就是Serial
收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial
收集器是完全一样的。
新生代采用复制算法,老年代采用标记-整理算法
它是许多运行在Server
模式下的虚拟机的首要选择,(Server
场景下默认的新生代收集器),主要是因为除了Serial
收集器外,只有它能与CMS
收集器(真正意义上的并发收集器)配合工作。
配置方式:如果老年代配置了使用 CMS 垃圾回收器,则新生代默认使用 ParNew,不需要显示配置。如果需要显示配置,则JVM参数为:-XX:+UseParNewGC
。其中 ParNew 和 CMS 的组合是响应时间优先的。如果年轻代的并行GC不想开启,可以通过设置-XX:-UseParNewGC
来关掉。
并行和并发概念补充:
并行(Parallel)
:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。并发(Concurrent)
:指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
Parallel Scavenge收集器
Parallel Scavenge
(并行清除)收集器也是使用复制算法的多线程收集器,其关注的重点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。Parallel Scavenge
收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,手工优化存在困难的话可以选择把内存管理优化交给虚拟机去完成。
新生代采用复制算法,老年代采用标记-整理算法
- JVM配置参数为:
-XX:+UseParallelGC
,该配置参数只对新生代有效,即新生代使用并行垃圾回收器,老年代使用Serial Old串行回收器。这个也是运行在 server 模式的JVM进程的默认垃圾收集器配置,即新生代 Parallel,老年代Serial Old。 - 吞吐量目标:Parallel 垃圾回收器为了实现可控制的吞吐量,通过JVM参数:
-XX:MaxGCPauseMillis
来控制垃圾回收的最大停顿时间,-XX:GCTimeRatio
直接控制吞吐量的大小。 - 可控制吞吐量的实现:通过JVM参数:
-XX:+UseAdaptiveSizePolicy
来开启动态调整堆的大小来达到吞吐量控制目的,此时不需要配置堆的新生代,老年代的大小,只需要配置基本的堆配置,如最大大小。通过JVM参数:-XX:ParallelGCThreads=20
配置并行收集器的线程数,一般设置为和处理器数量相同。
Serial Old收集器
Serical
收集器的老年代版本,同样是一个单线程的收集器,也是给Client
场景下的虚拟机使用。如果用在Server
场景下,它主要有两个作用:
- 在JDK1.5以及以前的版本中与
Parallel Scavenge
收集器搭配使用 - 作为
CMS
收集器的后备方案,在并发收集发生Concurrent Mode Failure
时使用。
配置方式为:-XX:+UseSerialGC
,此时老年代和新生代均使用串行垃圾回收器。
Parallel Old收集器
Parallel Scavenge
收集器的老年代版本,使用多线程和“标记-整理”算法,在注重吞吐量以及CPU资源的场合,都可以优先考虑Parallel Scavenge
和Parallel Old
收集器。
当新生代使用:-XX:+UseParallelGC
开启时,老年代使用的还是 Serial Old
,故需要显示配置:-XX:+UseParallelOldGC
来指定老年代使用并行 Parallel Old 垃圾回收器。
CMS收集器
CMS(Concurrent Mark Sweep)
收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合注重用户体验的应用上使用。
CMS
收集器是HotSpot虚拟机上第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep
这两个词可以看出,CMS
收集器是一种“标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
- 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
- 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除: 开启用户线程,同时 GC 线程开始对为标记的区域做清扫。
CMS
收集器的主要优点是:并发收集、低停顿。但它有下面三个明显的缺点:
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现
Concurrent Mode Failure
。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现Concurrent Mode Failure
,这时虚拟机将临时启用 Serial Old 来替代 CMS。 - 它使用的回收算法“标记-清除”算法会导致收集结束时有大量的空间碎片产生。往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
解决这个问题的办法就是可以让 CMS 在进行一定次数的 Full GC
(标记清除)的时候进行一次标记整理算法,CMS 提供了以下参数来控制:
-XX:UseCMSCompactAtFullCollection
-XX:CMSFullGCBeforeCompaction=5
使用-XX:+UseConcMarkSweepGC
开启CMS收集器
也就是 CMS 在进行5次 Full GC
(标记清除)之后进行一次标记整理算法,从而可以控制老年带的碎片在一定的数量以内,甚至可以配置 CMS 在每次 Full GC
的时候都进行内存的整理。
G1收集器
G1(Garbage-First)
是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短
Stop-The-World
停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。 - 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:与 CMS 的“标记–清理”算法不同,G1 从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
G1 收集器的运作大致分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
G1 把堆划分成多个大小相等的独立区域(Region
),新生代和老年代不再物理隔离。
通过引入Region
的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region
(地区,范围)(这也就是它的名字 Garbage-First 的由来)。这种使用Region
划分内存空间以及有优先级的区域回收方式,保证了 GF 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
每个Region
都有一个Remembered Set
,用来记录该Region
对象的引用对象所在的Region
。通过使用Remembered Set
,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护Remembered Set
的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的
Remembered Set Logs
里面,最终标记阶段需要把Remembered Set Logs
的数据合并到Remembered Set
中。这阶段需要停顿线程,但是可并行执行。 - 筛选回收:首先对各个
Region
中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region
,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
G1收集器具备如下特点:
- 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
G1的设计规则就是可以通过简单明了的方式来进行性能调优,典型配置只需要如以下配置:指定堆的最大大小,指定GC的最大停顿时间,则G1垃圾收集器会想办法满足这个目标。如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。
-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200
19. 类文件结构
在Java中,JVM可以理解的代码就叫做字节码
(即扩展名为.class
文件),它不面向任何特定的处理器,只面向虚拟机。Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特定。所以Java程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java程序无须重新编译便可在多种不同操作系统的计算机上运行。
Clojure(Lisp 语言的一种方言)、Groovy、Scala 等语言都是运行在 Java 虚拟机之上。下图展示了不同的语言被不同的编译器编译成.class文件最终运行在 Java 虚拟机之上。
可以说,.class
文件是不同语言在java虚拟机之间的重要桥梁,同时也是支持Java跨平台很重要的一个原因。
根据Java虚拟机规范,类文件由单个ClassFile
结构组成:
ClassFile {
u4 magic;//魔数,Class文件的标志
u2 minor_version;//Class的小版本号
u2 major_version;//Class的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//Class文件的字段属性
field_info fields[fields_count];//一个类会有多个字段
u2 methods_count;//Class文件的方法数量
method_info methods[methods_count];//一个类可以有多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
魔数
u4 magic;//魔数,Class文件的标志
每个Class
文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的Class文件。
程序设计者很多时候都喜欢用一些特殊的数字表示固定的文件类型或者其他特殊的含义
Class文件版本
u2 minor_version;//Class的小版本号
u2 major_version;//Class的大版本号
紧接着魔数的四个字节存储的是Class
文件的版本号:第五和第六是次版本号,第七和第八是主版本号。
高版本的Java虚拟机可以执行低版本编译器生成的Class
文件,但是低版本的Java虚拟机不能执行高版本编辑器生成的Class
文件,所以,我们在实际开发的时候要确保开发的JDK版本和生产环境的JDK版本保持一致。
常量池
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
紧接着主次版本号后的是常量池,常量池的数量是constant_pool_count-1
(常量池计数器从1开始计数,将第0项常量空出来是有特殊考虑的,索引值为0代表“不引用任何一个常量池项”)。
常量池主要存放两大常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final
的常量值等;而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
常量池中每一项常量都是一个表,这14种表有一个共同的特点:开始的第一位是一个u1类型的标志位tag
来标识常量的类型,代表当前这个常量属于哪种常量类型。
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整形字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型的字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中的方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段和方法的符号引用 |
CONSTANT_MethodType_info | 16 | 标志方法类型 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
.class
文件可以通过javap -v class类名
指令来看一下其常量池中的信息(javap -v class类名->temp.txt
:可以将结果输出到temp.txt
文件)
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口,是否为public
或者是abstract
类型,如果是类的话是否声明为final
等等。
类访问和属性修饰符:
当前类索引,父类索引与接口索引集合
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口
u2 interfaces[interfaces_count];//一个类可以实现多个接口
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于Java语言的单继承,所以父类索引只有一个,除了java.lang.Object
之外,所有的java类都有父类,因此除了java.lang.Object
外,所有Java类的父类索引都不为0。
接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口将按implements
(如果这个类本身是接口的话则是extends
)后的接口顺序从左到右排列在接口索引结合中。
字段表集合
u2 fields_count;//Class 文件的字段的个数
field_info fields[fields_count];//一个类会可以有个字段
字段表(field info
)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
access_flags
:字段的作用域(public
,private
,protected
修饰符),是实例变量还是类变量(static
修饰符),可否被序列化(transient
修饰符),可变性(final
修饰符),可见性(volatile
修饰符),是否强制从主内存读写)。name_index
:对常量池的引用,表示的字段的名称descriptor_index
:对常量池的引用,表示字段和方法的描述符attributes_count
:一个字段还会拥有一些额外的属性,如attributes_count
存放属性的个数attributes[attributes_count]
:存放具体属性的具体内容
上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型这些都是无法固定的,只能引用常量池中常量来描述。
字段access_flags
的取值:
方法表集合
u2 methods_count;//Class 文件的方法的数量
method_info methods[methods_count];//一个类可以有个多个方法
methods_count 表示方法的数量,而 method_info 表示的方法表。
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
方法表access_flag
取值:
因为volatile
修饰符和transient
修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized
、native
、abstract
等关键字修饰方法,所以也就多了这些关键字对应的标志。
属性表集合
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
在Class文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与Class
文件中其它数据项目要求的顺序、长度、内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会会忽略掉它不认识的属性。
20. 类加载过程
类加载过程就是将class
文件加载进内存,系统加载class
类型的文件主要分为三步:加载,连接,初始化,而连接过程又可以分为:验证,准备,解析三个过程。
类的生命周期还要加上使用和卸载两个过程
加载
类加载过程的第一步,主要完成下面3件事情:
- 通过全类名获取定义此类的二进制字节流
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
- 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
虚拟机规范对上面这3点的描述并不具体,因此是非常灵活的。比如:“通过全类名获取定义此类的二进制字节流”并没有指明具体从哪里获取、怎样获取。比如:比较常见的就是从ZIP
包中读取(日后出现的JAR
、EAR
、WAR
格式的基础)、其他文件生成(典型应用就是JSP
)等等。主要有以下几种方式:
- 从ZIP包读取,成为JAR、EAR、WAR格式的基础
- 从网络中获取,最典型的应用是
Applet
- 运行时计算生成,例如动态代理技术,在
java.lang.reflect.Proxy
使用ProxyGenerator.generatorProxyClass
的代理类的二进制字节流 - 由其他文件生成,例如由JSP文件生成对应的Class类
一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()
方法)。数组类型不通过类加载器创建,它由Java虚拟机直接创建。
加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
验证
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
这时候进行内存分配的仅包括类变量(static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
这里所设置的初始值”通常情况”下是数据类型默认的零值(如0
、0L
、null
、false
等),比如我们定义了public static int value=111
,那么value
变量在准备阶段的初始值就是0而不是111(初始化阶段才会复制)。
特殊情况:比如给value
变量加上了fianl
关键字public static final int value=111
,那么准备阶段value
的值就被复制为111。
基本数据类型的零值:
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。
符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方发表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。
初始化
初始化是类加载的最后一步,也是真正执行类中定义的 Java 程序代码(字节码),初始化阶段是执行类构造器<clinit> ()
方法的过程。
初始化阶段是虚拟机执行类构造器<clinit>()
方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
<clinit>()
是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。另外,由于父类的<clinit>()
方法先执行,也就意味着父类中定义的静态语句快的执行要优先于子类。
接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。
对于<clinit>()
方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit>()
方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。
对于初始化阶段,虚拟机严格规范了有且只有5种情况下,必须对类进行初始化:
- 当遇到
new
、getstatic
、putstatic
或invokestatic
这4条字节码指令时,(常见的生成这4条指令的场景有:new
一个类,读取一个静态字段(未被final
修饰)、或调用一个类的静态方法时)。 - 使用
java.lang.reflect
包的方法对类进行反射调用时,如果类没初始化,需要触发其初始化。 - 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
- 当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。 - 当使用 JDK1.7 的动态语言时,如果一个
MethodHandle
实例的最后解析结构为REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化。
以上5种情况的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:
- 通过子类引用父类的静态字段,不会导致子类初始化。
- 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自
Object
的子类,其中包含了数组的属性和方法。
SuperClass[] sca = new SuperClass[10];
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
System.out.println(ConstClass.HELLOWORLD);
21. 类加载器
两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。
这里的相等,包括类的 Class 对象的equals()
方法、isAssignableFrom()
方法、isInstance()
方法的返回结果为true,也包括使用instanceof
关键字做对象所属关系判定结果为true。
所有的类都是由类加载器加载,JVM中内置了三个重要的ClassLoader
,除了BootstrapClassLoader
,其他类加载器均由Java实现且全部继承自java.lang.ClassLoader
:
BootstrapClassLoader
(启动类加载器):最顶层的加载器,由C++实现,负责加载%JAVA_HOME%/lib
目录下的jar包和类或者被-Xbootclasspath
参数指定的路径中的所有类。类必须是虚拟机识别的(仅按照文件名识别,如rt.jar
,名字不符合的类库即使放在lib
目录中也不会被加载)。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用null
代替即可。ExtensionClassLoader
(扩展类加载器):这个类加载器是由ExtClassLoader
(sun.misc.Launcher$ExtClassLoader
)实现的。主要负责加载目录%JRE_HOME%/lib/ext
目录下的jar包和类,或被java.ext.dirs
系统变量所指定的路径下的jar包。AppClassLoader
(应用程序类加载器):这个类加载器由AppClassLoader
(sun.misc.Launcher$AppClassLoader
)实现的。由于这个类加载器是ClassLoader
中的getSystemClassLoader()
方法的返回值,因此一般称为系统类加载器。这是面向我们用户的加载类,负责加载当前应用的classpath
下的所有jar包和类。如果程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
22. 双亲委派模型
应用程序是由三种类加载器相互配合从而实现类加载,除此之外还可以加入自己定义的类加载器。
下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。
每一个类都有一个对应它的类加载器。系统中的ClassLoder
在协同工作的时候会默认使用双亲委派模型。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的loadClass()
处理,因此所有的请求最终都应该传送到顶层的启动类加载器BootstrapClassLoader
中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null
时,会使用启动类加载器BootstrapClassLoader
作为父类加载器。
每个类加载都有一个父类加载器,如下:
public class ClassLoaderDemo {
public static void main(String[] args) {
System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
}
}
输出:
ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@1b6d3586
The GrandParent of ClassLodarDemo's ClassLoader is null
AppClassLoader
的父类加载器为ExtClassLoader
。ExtClassLoader
的父类加载器为null
,**null并不代表其没有父类加载器,而是BootstrapClassLoader
**。
双亲委派模型实现源码分析
双亲委派模型的实现源码都集中在java.lang.ClassLoader
的loadClass()
中,相关代码如下:
// 用于委托的父类加载器
// 注意:VM硬编码了这个字段的偏移量,因此所有的新字段都必须在它之后添加。
private final ClassLoader parent;
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先检查请求的类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) { //父类加载器不为空,调用父类加载器的loadClass()方法处理
c = parent.loadClass(name, false);
} else { //父类加载器空,使用启动类加载器BootstrapClassLoader加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 抛出异常说明父类加载器无法完成加载请求
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
双亲委派模型的好处
双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。
例如java.lang.Object
存放在rt.jar
中,如果编写另外一个java.lang.Object
并放到ClassPath
中,程序可以编译通过。由于双亲委派模型的存在,所以在rt.jar
中的Object
比在ClassPath
中的Object
优先级更高,这是因为rt.jar
中的Object
使用的是启动类加载器,而ClassPath
中的Object
使用的是应用程序类加载器。rt.jar
中的Object
优先级更高,那么程序中所有的Object
都是这个Object
。
如果不想使用双亲委派模型怎么办
为了避免双亲委派机制,可以自己定义一个类加载器,然后重载loadClass()
方法
自定义类加载器
除了BootstrapClassLoader
,其他类加载器均由Java实现且全部继承自java.lang.ClassLoader
。如果要自定义类加载器,需要继承ClassLoader
抽象类.
以下代码中的FileSystemClassLoader
是自定义类加载器,继承自java.lang.ClassLoader
,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class
文件),然后读取该文件内容,最后通过defineClass()
方法来把这些字节代码转换成java.lang.Class
类的实例。
java.lang.ClassLoader
的loadClass()
实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写findClass()
方法。
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
return rootDir + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
}
}
23. Full GC的触发条件
对于Minor GC
,其触发条件非常简单,当Eden
空间满了之后,就将出发一次Minor GC
,而Full GC
则相对复杂,需要有以下条件:
- 调用
System.gc()
:只是建议虚拟机执行Full GC
,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。 - 老年代空间不足:老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过-Xmn
虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过-XX:MaxTenuringThreshold
调大对象进入老年代的年龄,让对象在新生代多存活一段时间。 - 空间分配担保失败:使用复制算法的
Minor GC
需要老年代的内存空间作担保,如果担保失败会执行一个Full GC
- JDK1.7以及以前的永久代空间不足:在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些
Class
的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用CMS GC
的情况下也会执行Full GC
。如果经过Full GC
仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError
。
为避免以上原因引起的Full GC
,可采用的方法为增大永久代空间或转为使用CMS GC
。 - Concurrent Mode Failure:执行
CMS GC
的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是GC
过程中浮动垃圾过多导致短暂性的空间不足,便会报Concurrent Mode Failure
,并触发Full GC
。
24. JVM是如何实现线程的?
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)
主流操作系统都提供了线程的实现,Java线程的关键方法都是声明 Native
,所以是直接使用了平台相关的方法去创建线程
JVM实现线程的方式主要有3种:
使用内核线程
内核线程(Kernel-Level Thread KLT) 就是直接由操作系统内核支持的线程,这种线程由内核来完成线程的切换。
内核通过操纵调度器 Scheduler 对线程进行调度,并负责将线程的任务映射到各个处理器上。
每个内核线程都可以视为内核的一个分身。
支持多线程的内核叫做多线程内核。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。轻量级进程与内核线程之间1:1的关系成为一对一线程模型。
局限性:
- 基于内核线程实现,线程操作(创建,析构、同步)都需要系统调用,代价相对比较高,需在用户态(User Mode)和内核态(Kernel Mode)中来回切换
- 需要消耗内核资源(如内核的栈空间)
使用用户线程
广义上面讲,一个线程只要不是内核线程,就可以认为是用户线程
从定义上讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制
狭义上面讲,完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现
用户线程的建立、同步、销户和调度完全在用户态中完成,不需要内核的帮助
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的
这种进程与用户线程之间1:N的关系称为一对多的线程模型
- 优势:不需要内核支援
- 劣势:没有系统内核的支援,所有的线程操作都需要用户程序自己处理,包括线程的创建、切换和调度,阻塞如何处理,如何将线程映射到其他处理器上。因为用户线程实现程序比较复杂,所以使用用户线程的程序越来越少
使用用户线程加轻量级进程混合实现
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
轻量级进程作为桥梁,可以使用内核提供线程调度功能以及处理器映射功能,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。
这种关系为N:M关系,多对多的线程模型
JAVA线程的实现
操作系统支持怎么样的线程模型,在很大的程度上决定了 Java 虚拟机上的线程是怎么样映射的。
线程调度是系统为线程分配处理器使用权的过程。
协同式线程调度
线程的执行时间由线程本身控制,线程把自己的工作执行完,主动通知系统切换到另外一个线程
好处:
- 实现简单
- 切换操作堆线程自己是可知的,所以没有什么线程同步问题
坏处:
- 线程执行时间不可控制,如果一个线程编写有问题,那程序一直会阻塞在那里
抢占式线程调度(JAVA线程实现方式)
线程的切换不由线程本身来做决定,每个线程由系统来分配执行时间,不会有线程导致整个进程阻塞的问题。
好处:
- 执行时间是可控的,不会有一个线程导致整个进程阻塞
- 使用优先级来建议系统对某个线程多分配执行时间
java语言提供了10个级别的线程优先级,但是并不能完全依靠线程优先级。因为Java的线程是被映射到系统的原生线程上,所以线程调度最终还是由操作系统说了算。如 Windows 系统中存在一个“优先级推进器”,当系统发现一个线程执行特别勤奋,可能会越过线程优先级为它分配执行时间。
25. Java中会存在内存泄漏吗?请简单描述
内存泄露就是指一个不再被程序使用的对象或变量一直被占据在内存中。java 中有垃圾回收机制,它可以保证一对象不再被引用的时候,即对象变成了孤儿的时候,对象将自动被垃圾回收器从内存中清除掉。由于 Java 使用有向图的方式进行垃圾回收管理,可以消除引用循环的问题,例如有两个对象,相互引用,只要它们和根进程不可达的,那么GC也是可以回收它们的
java中的内存泄露的情况:
- 长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是 java 中内存泄露的发生场景,通俗地说,就是程序员可能创建了一个对象,以后一直不再使用这个对象,这个对象却一直被引用,即这个对象无用但是却无法被垃圾回收器回收的,这就是 java 中可能出现内存泄露的情况,例如,缓存系统,我们加载了一个对象放在缓存中(例如放在一个全局map对象中),然后一直不再使用它,这个对象一直被缓存引用,但却不再被使用。
检查 java 中的内存泄露,一定要让程序将各种分支情况都完整执行到程序结束,然后看某个对象是否被使用过,如果没有,则才能判定这个对象属于内存泄露。 - 如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持久外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。
- 当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在
contains
方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中单独删除当前对象,造成内存泄露。
26. Java 对象一定在堆内存分配吗?
不一定,随着 JIT 编译器的发展,在编译期间,如果 JIT 经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化为栈内存分配。但这并不是绝对的。
JIT:在Java编程语言和环境中,即时编译器(JIT compiler,just-in-time compiler)是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。
逃逸分析(Escape Analysis):目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
分析
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
例如:
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
StringBuffer sb
是一个方法内部变量,上述代码中直接将 sb
返回,这样这个 StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
上述代码如果想要 StringBuffer sb
不逃出方法,可以这样写:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
不直接返回 StringBuffer,那么 StringBuffer 将不会逃逸出方法。
使用逃逸分析,编译器可以对代码做如下优化:
- 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。(锁优化中的锁消除)
- 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
在 Java 代码运行时,通过 JVM 参数可指定是否开启逃逸分析,
- -XX:+DoEscapeAnalysis : 表示开启逃逸分析
- -XX:-DoEscapeAnalysis : 表示关闭逃逸分析
从 jdk1.7 开始,默认是开启逃逸分析的
在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着 JIT 编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT 编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。
27. JVM性能监测常用指令(Java内存占用过大怎么查看?/Java内存溢出怎么定位)
top
top指令:查看当前所有进程的使用情况,CPU占有率,内存使用情况,服务器负载状态等参数。(系统指令,不属于 JVM 工具)
jps
jps:与linux上的 ps 类似,用于查看有权访问的虚拟机的进程,可以查看本地运行着几个 java 程序,并显示他们的进程号。当未指定 hostid 时,默认查看本机 jvm 进程。
指令格式:jps [options] [hostid]
jps -l
: 输出应用程序主类完整 package 名称或 jar 完整名称jps -v
: 列出 jvm 的启动参数
jinfo
jinfo:可以输出并修改运行时的 java 进程的一些参数。
指令格式:jinfo [ option ] pid
jinfo pid
: 输出全部参数和系统属性jinfo pid -flags pid
: 只输出参数
jstat
jstat:可以用来监视 jvm 内存内的各种堆和非堆的大小及其内存使用量。
指令格式:jstat [options] [pid] [间隔时间/毫秒] [查询次数]
jstat -gcutil pid 1000 100
: 1000毫秒统计一次 gc 情况,统计100次jstat -class pid
: 类加载统计,输出加载和未加载class的数量及其所占空间的大小jstat -compiler pid
: 编译统计,输出编译和编译失败数量及失败类型与失败方法
jstack
jstack:堆栈跟踪工具,一般用于查看某个进程包含线程的情况。
指令格式:jstack [options] [pid]
jstack -l pid
: 查看jvm线程的运行状态,是否有死锁等信息
jmap
jmap:打印出某个 java 进程(使用pid)内存内的所有对象的情况。一般用于查看内存占用情况。
指令格式:jmap [ option ] pid
jmap [ option ] executable core
: 产生核心dump的Java可执行文件,dump就是堆的快照,内存镜像jmap [ option ] [server-id@]remote-hostname-or-IP
: 通过可选的唯一id与调试的远程服务器主机名进行操作jmap -histo:live pid
: 输出堆中活动的对象以及大小jmap -heap pid
: 查看堆的使用状况信息jmap -permstat pid
: 打印进程的类加载器和类加载器加载的持久代对象信息
jconsole
jconsole :一个java GUI监视工具,可以以图表化的形式显示各种数据。并可通过远程连接监视远程的服务器的jvm进程。bin 目录下的工具,支持远程连接,可以查看JVM的概述,内存,线程等详细情况。
28. JVM 调优参数
堆参数
回收器参数
目前主要有串行、并行和并发三种,对于大内存的应用而言,串行的性能太低,因此使用到的主要是并行和并发两种。并行和并发 GC 的策略通过 UseParallelGC
和 UseConcMarkSweepGC
来指定,还有一些细节的配置参数用来配置策略的执行方式。例如:XX:ParallelGCThreads
, XX:CMSInitiatingOccupancyFraction
等。 通常:Young 区对象回收只可选择并行(耗时间),Old 区选择并发(耗 CPU)。
项目中常用参数
常用组合
常用 GC 调优策略
GC 调优原则
在调优之前,需要记住下面的原则:
多数的 Java 应用不需要在服务器上进行 GC 优化; 多数导致 GC 问题的 Java 应用,都不是因为我们参数设置错误,而是代码问题; 在应用上线之前,先考虑将机器的 JVM 参数设置到最优(最适合); 减少创建对象的数量; 减少使用全局变量和大对象; GC 优化是到最后不得已才采用的手段; 在实际使用中,分析 GC 情况优化代码比优化 GC 参数要多得多。
GC 调优目的
将转移到老年代的对象数量降低到最小; 减少 GC 的执行时间。
GC 调优策略
策略 1 : 将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。
策略 2 : 大对象进入老年代,虽然大部分情况下,将对象分配在新生代是合理的。但是对于大对象这种做法却值得商榷,大对象如果首次在新生代分配可能会出现空间不足导致很多年龄不够的小对象被分配的老年代,破坏新生代的对象结构,可能会出现频繁的 full gc。因此,对于大对象,可以设置直接进入老年代(当然短命的大对象对于垃圾回收来说简直就是噩梦)。-XX:PretenureSizeThreshold
可以设置直接进入老年代的对象大小。
策略 3 : 合理设置进入老年代对象的年龄,-XX:MaxTenuringThreshold
设置对象进入老年代的年龄大小,减少老年代的内存占用,降低 full gc 发生的频率。
策略 4 : 设置稳定的堆大小,堆大小设置有两个参数:-Xms 初始化堆大小,-Xmx 最大堆大小。
策略5 : 注意: 如果满足下面的指标,则一般不需要进行 GC 优化:
- MinorGC 执行时间不到50ms;
- Minor GC 执行不频繁,约10秒一次;
- Full GC 执行时间不到1s;
- Full GC 执行频率不算频繁,不低于10分钟1次。
29. JVM 在哪些情况下会抛出 OOM 异常
OOM 可能出现的消息有:
- java.lang.OutOfMemoryError: Java heap space
- java.lang.OutOfMemoryError: PermGen space
- java.lang.OutOfMemoryError: Metaspace
- java.lang.OutOfMemoryError: Requested array size exceeds VM limit
- java.lang.OutOfMemoryError: request bytes for . Out of swap space?
- java.lang.OutOfMemoryError: (Native method)
Java 堆溢出(Java heap space)
java 堆用于存储对象实例,只要不断地创建对象,并且这些对象不会被回收(什么情况对象不会被回收呢?如:由于 GC Root 到对象之间有可达路径,所以垃圾回收机制不会清除这些对象),那么,当对象的数量达到一定的数量,从而达到了最大堆容量(-Xmx)限制了,这个时候会产生内存溢出异常。
java堆内存溢出异常的堆栈信息:java.lang.OutOfMemoryError:java heap space
解决方法:
首先要确认内存中的对象是否是必要的,也就是要区分出现的是内存泄露(Memory Leak)还是内存溢出(Memory Overflow)
如果是内存泄露,要使用工具查看泄露对象到 GC Roots 的引用链,找到泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动回收它们。
如果不是内存泄露,那么就要检查JVM参数(-Xmx与-Xms),根据机器物理内存情况看看是否能把参数调大一些,另一方面,从代码层面考虑,看看是否存在某些对象生命周期过长、持有状态时间过长的情况,优化代码,从而尝试减少程序在运行期的内存消耗。
方法区(永久代)已满(PermGen space)
此错误,为内存溢出错误。更具体的说,是指方法区(永久代)内存溢出,表明永久代已满。永久代是存储类和方法对象的堆的区域。
解决方案:
在JDK1.6及之前的版本中,常量池分配在永久带内,可以通过 -XX:PermSize
和 -XX:MaxPermSize
限制方法区的大小,从而间接来限制常量池的大小。
Metaspace内存溢出
元空间的溢出,系统会抛出 java.lang.OutOfMemoryError: Metaspace
。出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
解决方案:
默认情况下,元空间的大小仅受本地内存限制。但是为了整机的性能,尽量还是要对该项进行设置,以免造成整机的服务停机。
- 优化参数配置,避免影响其他JVM进程
-XX:MetaspaceSize
,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize
时,适当提高该值。-XX:MaxMetaspaceSize
,最大空间,默认是没有限制的。
除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
-XX:MinMetaspaceFreeRatio
,在 GC 之后,最小的 Metaspace 剩余空间容量的百分比,减少为分配空间所导致的垃圾收集 。-XX:MaxMetaspaceFreeRatio
,在 GC 之后,最大的 Metaspace 剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
- 慎重引用第三方包
对第三方包,一定要慎重选择,不需要的包就去掉。这样既有助于提高编译打包的速度,也有助于提高远程部署的速度。
- 关注动态生成类的框架
对于使用大量动态生成类的框架,要做好压力测试,验证动态生成的类是否超出内存的需求会抛出异常。
数组超限内存溢出(Requested array size exceeds VM limit)
此错误表示应用程序(或该应用程序使用的API)尝试分配大于堆大小的数组。例如,如果应用程序尝试分配512MB 的数组但最大堆大小为 256MB,则将抛出此错误消息的 OOM。在大多数情况下,是配置问题或应用程序尝试分配海量数组时导致的错误。
超出交换区内存溢出(request bytes for . Out of swap space)
在Java应用程序启动过程中,可以通过 -Xmx
和其他类似的启动参数限制指定的所需的内存。而当 JVM 所请求的总内存大于可用物理内存的情况下,操作系统开始将内容从内存转换为硬盘。
当本机堆的分配失败并且本机堆可能将被耗尽时,HotSpot VM 会抛出此异常。消息中包括失败请求的大小(以字节为单位)以及内存请求的原因。在大多数情况下,是报告分配失败的源模块的名称。
如果抛出此类型的OOM,则可能需要在操作系统上使用故障排除实用程序来进一步诊断问题。在某些情况下,问题甚至可能与应用程序无关。例如,可能会在以下情况下看到此错误:
- 操作系统配置的交换空间不足。
- 系统上的另一个进程是消耗所有可用的内存资源。
由于本机泄漏,应用程序也可能失败(例如,如果某些应用程序或库代码不断分配内存但无法将其释放到操作系统)。
本地方法溢出内存溢出(Native method)
此错误消息并且堆栈跟踪的顶部框架是本机方法,则该本机方法遇到分配失败。此消息与上一个消息之间的区别在于,在 JNI 或本机方法中检测到 Java 内存分配失败,而不是在 Java VM 代码中检测到。
30. 怎么写一个栈溢出、OOM 的代码?
栈溢出:循环递归调用
OOM:分配大数组
参考内容
主要参考以来两篇博客以及相关博客推荐,因找的博客比较多,没注意记录,最后好多忘了在哪2333,如果有侵权,请及时联系我,非常抱歉。
https://github.com/Snailclimb/JavaGuide
https://github.com/CyC2018/CS-Notes
源码分析:Java堆的创建
十种JVM内存溢出的情况,你碰到过几种?
如何排查Java内存泄漏?看完我给跪了!
对JVM中可能出现内存溢出(OOM)情况的整理
JVM垃圾收集之可达性分析
Java 虚拟机枚举 GC Roots 解析
JVM垃圾回收系列—GC Roots可达性分析