本文是在JavaGuide面试突击版的基础上扩展而来。
1. 介绍下 Java 内存区域(运⾏时数据区)
Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。(JDK. 1.8 和之前的版本略有不同,下⾯会介绍到。)
JDK 1.8之前:
JDK 1.8 :
线程私有的:程序计数器、虚拟机栈、本地⽅法栈。
线程共享的:堆、⽅法区、直接内存(⾮运⾏时数据区的⼀部分)。
2. 程序计数器
主要有两个作⽤:
- 字节码解释器通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制,如:顺序执⾏、选择、循环、异常处理。
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪⼉了。
注意:程序计数器是唯⼀⼀个不会出现 OutOfMemoryError 的内存区域,它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。
3. 虚拟机栈、⽅法/函数如何调⽤?
Java虚拟机栈是线程私有的,它的⽣命周期和线程相同,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡。Java虚拟机栈是由⼀个个栈帧组成,⽽每个栈帧中都拥有:局部变量表(主要存放了编译器可知的各种数据类型和对象引⽤)、操作数栈、动态链接、方法返回地址。虚拟机栈是Java方法执行的内存模型,每个Java方法的执行对应着一个栈帧的进栈和出栈的操作。
问:那么⽅法/函数如何调⽤?
虚拟机栈可类⽐数据结构中栈,其中保存的主要内容是栈帧,每⼀次函数调⽤都会有⼀个对应的栈帧被压⼊,每⼀个函数调⽤结束后,都会有⼀个栈帧被弹出。
Java⽅法有两种返回⽅式:1. return 语句。2. 抛出异常。不管哪种返回⽅式都会导致栈帧被弹出。
虚拟机栈规定了两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常;
4. 本地⽅法栈、native关键字
和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中,本地⽅法栈和 虚拟机栈合⼆为⼀。
本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、方法返回地址。
⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种异常。
native关键字:
被native关键字修饰的方法叫做本地方法,本地方法和其它方法不一样,本地方法意味着和平台有关,因此使用了native的程序可移植性都不太高。另外native方法在JVM中运行时数据区也和其它方法不一样,它有专门的本地方法栈。native方法主要用于加载文件和动态链接库,由于Java语言无法访问操作系统底层信息(比如:底层硬件设备等),这时候就需要借助C语言来完成了。被native修饰的方法可以被C语言重写。
5. 堆
Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。
此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。
java进程运行过程中创建的对象存放在堆中,从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存。
堆的内存模型大致为:
新生代 ( Young ) = 1/3 的堆空间大小,老年代 ( Old ) = 2/3 的堆空间大小。( 该值可以通过参数 –XX:NewRatio 来指定 )
其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。From Survivor区域与To Survivor区域是交替切换空间,在同一时间内两者中只有一个不为空。
6. ⽅法区/永久代(PermGen)
⽅法区也被称为永久代。永久代就是HotSpot虚拟机对虚拟机规范中⽅法区的⼀种实现⽅式。 ⽅法区与 Java 堆⼀样,是各个线程共享的内存区域,它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把⽅法区描述为堆的⼀个逻辑部分,但是它却有⼀个别名叫做 Non-Heap(⾮堆),⽬的是与 Java 堆区分开来。
常见参数:
-XX:PermSize=N //⽅法区(永久代)初始⼤⼩
-XX:MaxPermSize=N //⽅法区(永久代)最⼤⼤⼩,超过这个值将会抛出OutOfMemoryError异常
相对⽽⾔,垃圾收集⾏为在这个区域是⽐较少出现的,但并⾮数据进⼊⽅法区后就“永久存在”了。
7. 元空间(MetaSpace)
JDK 1.8 的时候,⽅法区(HotSpot的永久代)被彻底移除了(JDK1.7就已经开始了),取⽽代之是元空间,元空间使⽤的是直接内存。与永久代最大的不同就是,元空间的大小默认只受本地内存限制。
为什么要将永久代(PermGen)替换为元空间(MetaSpace)呢?
因为永久代设置空间大小很难确定。
一个应用动态加载的类的数目是很难确定的,如果永久代设置的过小,会频繁触发OOM。
而元空间的大小默认只受本地内存限制,这样出现OOM的机会比较小。
⼀些常⽤参数:
-XX:MetaspaceSize=N //设置Metaspace的初始⼤⼩,如果未指定此标志,则 Metaspace 将根据运⾏时的应⽤程序需求动态地重新调整⼤⼩。
-XX:MaxMetaspaceSize=N //设置Metaspace的最⼤⼤⼩,默认值为 unlimited,这意味着它只受系统内存的限制。
8. 运⾏时常量池
运⾏时常量池是⽅法区的⼀部分,⽤于存放编译期⽣成的各种字⾯量和符号引⽤。既然运⾏时常量池是⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池。
9. 直接内存
直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存⼤⼩以及处理器寻址空间的限制。
10. Out Of Memory
什么是OOM?
OOM,全称“Out Of Memory”,来源于java.lang.OutOfMemoryError。当JVM因为没有足够的内存来为对象分配空间,并且垃圾回收器也已经没有空间可回收时,就会抛出这个error(注:非exception,因为这个问题已经严重到不足以被应用处理)。
为什么会OOM?
为什么会没有内存了呢?原因不外乎有两点:
1)可供分配的内存少了:比如虚拟机本身可使用的内存太少了。
2)应用用的太多,并且用完没释放,浪费了。此时就会造成内存泄露或者内存溢出。
内存泄露: 指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。(这些对象是可达的,即GC Roots到对象之间有引用链相连,那么GC无法回收。这些对象是无用的,即程序以后不会再用到这些对象。)
内存溢出: 指程序运行过程中无法申请到足够的内存而导致的一种错误。
从定义上可以看出内存泄露是内存溢出的一种诱因,但是不是唯一因素。
按照JVM规范,在Java 内存区域中,除了程序计数器不会抛出OOM外,其他各个内存区域(虚拟机栈、本地方法栈、堆、方法区(JDK 1.8后,⽅法区(HotSpot的永久代)变为元空间)、运行时常量池、直接内存)都可能会抛出OOM。
常见的OOM情况:
java.lang.OutOfMemoryError:
- Java heap space ------>java堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。对于内存泄露,需要通过内存监控软件(mat)查找程序中的泄露代码,而堆大小设置不当可以通过虚拟机参数-Xms,-Xmx等修改。
(-Xms 初始堆大小 默认值为 物理内存的1/64(<1GB), -Xmx 最大堆大小 默认值为 物理内存的1/4(<1GB) 。) - PermGen space ------>java永久代溢出,即方法区溢出了,一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况,因为上述情况会产生大量的Class信息存储于方法区。此种情况可以通过更改方法区的大小来解决(使用类似-XX:PermSize=64m -XX:MaxPermSize=256m的形式修改)。另外,过多的常量尤其是字符串也会导致方法区溢出。
java.lang.StackOverflowError
------> 不会抛OOM error,但也是比较常见的Java内存溢出,即JAVA虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的,栈大小设置太小也会出现此种溢出。可以通过虚拟机参数-Xss来设置栈的大小。
11. 内存泄漏
内存泄漏和内存溢出的关系:
内存泄露: 指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。(这些对象是可达的,即GC Roots到对象之间有引用链相连,那么GC无法回收。这些对象是无用的,即程序以后不会再用到这些对象。)
内存溢出: 指程序运行过程中无法申请到足够的内存而导致的一种错误。
从定义上可以看出内存泄露是内存溢出的一种诱因,但是不是唯一因素。
可以使用Runtime.getRuntime().freeMemory()查询当前还有多少空闲内存,在某语句前后使用可以判断该语句是否存在内存泄漏。
System.out.println("free内存:" + Runtime.getRuntime().freeMemory() / 1024 / 1024);
String[] aaa = new String[2000000];
for (int i = 0; i < 2000000; i++) {
aaa[i] = new String("aaa");
}
System.out.println("free内存:" + Runtime.getRuntime().freeMemory() / 1024 / 1024);
此时结果如下所示:
内存泄漏的例子:
- 如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。
比如下面的代码,这里的object实例,我们期望它只作用于method1()方法中,但是,当method1()方法执行完成后,object对象所分配的内存不会马上被认为是可以被释放的对象,只有在Simple类创建的对象被释放后才会被释放,严格的说,这就是一种内存泄露。
解决方案是将使用完毕后将该对象设置为null。
public class Simple {
Object object;
public void method1(){
object = new Object();
//object = null;//解决内存泄漏的语句
}
}
- 集合里面的内存泄漏
虽然集合里面的数据都设置成null,但是集合内存还是存在的,集合里面的所有元素都不会进行垃圾回收。
解决方案就是把list集合变量也变成null。
list = null;
- 连接没有关闭会造成内存泄漏
比如数据库连接,网络连接和io连接,这些链接在使用的时候,除非显式的调用了其close()方法(或类似方法)将其连接关闭,否则是不会自动被GC回收的。其实原因依然是长生命周期对象持有短生命周期对象的引用。所以在连接调用结束的时候要进行调用close()进行关闭,这样可以回收不用的内存对象,增加可用内存。 - ThreadLocal 内部的value可能会造成内存泄漏
ThreadLocal 内部维护的是⼀个类似 Map 的ThreadLocalMap 数据结构, key 为当前对象的 Thread 对象,值为 Object 对象。ThreadLocalMap 中使⽤的 key 为 ThreadLocal 的弱引⽤,⽽ value 是强引⽤。所以,如果ThreadLocal 没有被外部强引⽤的情况下,在垃圾回收的时候,key 会被清理掉,⽽ value 不会被清理掉。假如不做任何措施的话,value 永远⽆法被GC 回收,这个时候就可能会产⽣内存泄露。在ThreadLocalMap的实现中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null 的记录。另外,使⽤完ThreadLocal ⽅法后最好⼿动调⽤ remove() ⽅法。
12.JVM参数
-Xms 初始堆大小 默认值:物理内存的1/64(<1GB)
默认空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。
-Xmx 最大堆大小 默认值:物理内存的1/4(<1GB)
默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制。
-Xss 每个线程的堆栈大小
JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K,可根据应用的线程所需内存大小进行 调整,在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。一般小的应用, 如果栈不是很深, 应该是128k够用的 大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。
-Xmn 年轻代大小。
-XX:NewSize 设置年轻代大小
-XX:MaxNewSize 年轻代最大值
-XX:NewRatio 年轻代与年老代的比值
-XX:SurvivorRatio Eden区与Survivor区的大小比值
-XX:PermSize 设置持久代(perm gen)初始值 默认值:物理内存的1/64。
-XX:MaxPermSize 设置持久代最大值 默认值:物理内存的1/4。
13. 栈内存、堆内存
Java 把内存划分成两种:一种是栈内存,另一种是堆内存。
基本类型变量和对象的引用变量都在函数的栈内存中分配: 当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间,当超过变量的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。
堆内存用来存放由new创建的对象和数组: 在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。
java中变量在内存中的分配:
1. 类变量(static修饰的变量): 在程序加载时系统就为它在堆中开辟了内存,堆中的内存地址存放于栈以便于高速访问。静态变量的生命周期–一直持续到整个"系统"关闭
2. 实例变量: 系统在堆中开辟并不一定是连续的空间分配给变量,然后根据零散的堆内存地址,通过哈希算法换算为一长串数字以表征这个变量在堆中的"物理位置"。 实例变量的生命周期–当实例变量的引用丢失后,将被GC(垃圾回收器)列入可回收“名单”中,但并不是马上就释放堆中内存
3. 局部变量: 声明在某方法,或某代码段里(比如for循环),执行到它的时候在栈中开辟内存,当局部变量一但脱离作用域,内存立即释放。
14.对象的访问定位 使⽤句柄、直接指针
建⽴对象就是为了使⽤对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问⽅式由虚拟机实现⽽定,⽬前主流的访问⽅式有①使⽤句柄和②直接指针两种:
- 句柄: 如果使⽤句柄的话,那么Java堆中将会划分出⼀块内存来作为句柄池,reference 中存储的就是对象的句柄地址,⽽句柄中包含了对象实例数据与类型数据各⾃的具体地址信息;
- 直接指针: 如果使⽤直接指针访问,那么 Java 堆对象中就必须有访问类型数据的相关信息,⽽reference 中存储的直接就是对象的地址。
这两种对象访问⽅式各有优势。使⽤句柄来访问的最⼤好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,⽽ reference 本身不需要修改。而使⽤直接指针访问⽅式最⼤的好处就是速度快,它节省了⼀次指针定位到实例对象的时间开销。
15 Java对象的创建过程
①类加载检查:
根据new的参数在常量池中定位一个类的符号引用。如果没有找到这个符号引用,说明类还没有被加载,则进行类的加载,解析和初始化。
②分配内存:
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
③初始化零值:
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头:
初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
⑤执⾏ init ⽅法:
把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
16. 对象分配内存、并发问题
1. 划分内存的方法
“指针碰撞”(Bump the Pointer)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,此时划分内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
“空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录。
解决并发问题的方法:
(在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。)
1. CAS(compare and swap)
虚拟机采用CAS加上失败重试的方式保证更新操作的原子性,来对分配内存空间的动作进行同步处理。
2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分在不同的空间之中进行,即为每⼀个线程预先在Eden区分配⼀块⼉内存,JVM在给线程中的对象分配内存时,⾸先在TLAB分配,当对象⼤于TLAB中的剩余内存或TLAB的内存已⽤尽时,再采⽤上述的CAS进⾏内存分配。
17. 堆中新生代GC回收策略
问:为什么会有年轻代?
为什么需要把堆分代?不分代不能完成他所做的事情么?其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那我们所有的对象都在一块,GC的时候我们要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,如果分代的话,我们把新创建的对象放到某一地方(新生代),当GC的时候先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
问:新生代中的GC策略
HotSpot VM把新生代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8::1:1。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(默认为15岁)(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到老年代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
因为新生代中的对象基本都是朝生夕死的(80%以上),所以在新生代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
问:为什么要有Survivor区?
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代(2倍),进行一次Full GC消耗的时间比Minor GC长得多。频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,而且某些连接会因为超时发生连接错误。
Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历15次Minor GC还能在新生代中存活的对象,才会被送到老年代。
问:为什么要设置两个Survivor区?
设置两个Survivor区最大的好处就是解决了碎片化。
假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,无法为后续内存需求量较大的对象分配空间。
顺理成章的,应该建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空,并且存活对象的年龄还会加 1;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。当对象的年龄增加到⼀定程度(默认为15岁),该对象就会被送到老年代中。
上述机制最大的好处就是,整个过程中,永远有一个survivor space是空的,另一个非空的survivor space无碎片。
那么,Survivor为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满,因此,我认为两块Survivor区是经过权衡之后的最佳方案。
大对象直接进入老年代
有一个JVM参数,就是“-XX:PretenureSizeThreshold”,可以把它的值设置为字节数,比如“1048576”字节,就是1MB
意思就是如果你要创建一个大于这个大小的对象,比如一个超大的数组,此时就直接把这个大对象放到老年代里去,压根不会经过年轻代。
之所以这么做,是因为要避免年轻代里出现那种大对象,然后屡次躲过GC,还得把他在两个Survivor区域里来回复制多次之后才能进入老年代。
18. Minor GC、Major GC、Full GC
Minor GC:
指发⽣新⽣代的的垃圾收集动作,当 eden 区没有⾜够空间进⾏分配时,虚拟机将发起⼀次Minor GC。Minor GC⾮常频繁,回收速度⼀般也⽐较快。所有的Minor GC都会触发全世界的暂停(stop-the-world),停止应用程序的线程,不过这个过程非常短暂。
Major GC:
指发⽣在⽼年代的GC,出现了Major GC经常会伴随⾄少⼀次的Minor GC(并⾮绝对),Major GC的速度⼀般会⽐Minor GC的慢10倍以上。
Full GC:
针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC;
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold (阈值)来设置。
19. 判断对象死亡:引⽤计数法、可达性分析法
堆中⼏乎放着所有的对象实例,对堆垃圾回收前的第⼀步就是要判断哪些对象已经死亡(即不能再被任何途径使⽤的对象)。
引⽤计数法
给对象中添加⼀个引⽤计数器,每当有⼀个地⽅引⽤它,计数器就加1;当引⽤失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使⽤的。
可达性分析法
这个算法的基本思想就是通过⼀系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所⾛过的路径称为引⽤链,当⼀个对象到 GC Roots 没有任何引⽤链相连的话,则证明此对象是不可⽤的。
Java中可作为GC Roots 的对象有哪些?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 本地方法栈中JNI(即一般说的native方法)引用的对象。
- 方法区中的静态变量和常量引用的对象。
20. 强引⽤,软引⽤,弱引⽤,虚引⽤
⽆论是通过引⽤计数法判断对象引⽤数量,还是通过可达性分析法判断对象的引⽤链是否可达,判定对象的存活都与“引⽤”有关。
JDK1.2以后,Java对引⽤的概念进⾏了扩充,将引⽤分为强引⽤、软引⽤、弱引⽤、虚引⽤四种(引⽤强度逐渐减弱)
强引⽤(StrongReference)
我们使⽤的⼤部分引⽤实际上都是强引⽤,这是使⽤最普遍的引⽤。如果⼀个对象具有强引⽤,那就类似于必不可少的⽣活⽤品,垃圾回收器绝不会回收它。当内存空间不⾜,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。
软引⽤(SoftReference)
如果⼀个对象只具有软引⽤,如果内存空间⾜够,垃圾回收器就不会回收它,如果内存空间不⾜了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使⽤。软引⽤可⽤来实现内存敏感的⾼速缓存。
软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收,JAVA虚拟机就会把这个软引⽤加⼊到与之关联的引⽤队列中。
弱引⽤(WeakReference)
弱引⽤与软引⽤的区别在于: 只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。
弱引⽤也可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。
虚引⽤(PhantomReference)
"虚引⽤"顾名思义,就是形同虚设,与其他⼏种引⽤都不同,虚引⽤并不会决定对象的⽣命周期。如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被垃圾回收。
虚引⽤主要⽤来跟踪对象被垃圾回收的活动。
虚引⽤与软引⽤和弱引⽤的⼀个区别在于: 虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤。当垃圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。那么就可以回收之前采取必要的⾏动。
特别注意,在程序设计中⼀般很少使⽤弱引⽤与虚引⽤,使⽤软引⽤的情况较多,这是因为软引⽤可以加速JVM对垃圾内存的回收速度,可以维护系统的运⾏安全,防⽌内存溢出(OutOfMemory)等问题的产⽣。
21.如何判断⼀个常量是废弃常量
运⾏时常量池主要回收的是废弃的常量。那么,我们如何判断⼀个常量是废弃常量呢?
假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引⽤该字符串常量的话,就说明常量"abc" 就是废弃常量,如果这时发⽣内存回收⽽且有必要的话,“abc” 就会被系统清理出常量池。
22. 如何判断⼀个类是⽆⽤的类
⽅法区主要回收的是⽆⽤的类,那么如何判断⼀个类是⽆⽤的类的呢?
类需要同时满⾜下⾯3个条件才能算是 “⽆⽤的类” :
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地⽅被引⽤,⽆法在任何地⽅通过反射访问该类的⽅法。
虚拟机可以对满⾜上述3个条件的⽆⽤类进⾏回收,这⾥说的仅仅是“可以”,⽽并不是和对象⼀样不使⽤了就会必然被回收。
23.垃圾收集算法
1. 标记-清除算法
算法分为“标记”和“清除”阶段:⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象。
这种算法不需要对对象进行移动操作,仅对可回收对象进行操作,所以在对象存活率较高的情况下效率非常高,但是对象被回收后,可用的内存并不是连续的,造成大量的内存碎片。
2.复制算法
复制算法是将内存分为两块大小一样的区域,每次是使用其中的一块。当这块内存块用完了,就将这块内存中还存活的对象复制到另一块内存中,然后清空这块内存。
这种算法在对象存活率较低的场景下效率很高,在垃圾回收的过程也不会出现内存碎片的情况,实现简单,所以运行效率很高。
3. 标记-整理算法
根据⽼年代的特点(对象存活率较高)提出的⼀种标记算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象回收,⽽是让所有存活的对象向⼀端移动,然后直接清理掉端边界以外的内存。
4. 分代收集算法
当前虚拟机的垃圾收集都采⽤分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为⼏块。⼀般将java堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
⽐如在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。⽽⽼年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。
24. 常⻅的垃圾回收器
如果说收集算法是内存回收的⽅法论,那么垃圾收集器就是内存回收的具体实现。
新生代收集器使用的收集器:Serial、PraNew、Parallel Scavenge;老年代收集器使用的收集器:Serial Old、Parallel Old、CMS;以及G1收集器。
图中的垃圾收集器之间存在连线,说明它们可以搭配使用。 实际上,目前没有任何一款收集器是最好的,只能说针对不同应用场景选择更合适的收集器。
1. Serial收集器 (复制算法)
Serial收集器是最基本、最悠久的收集器。该收集器是一个新生代单线程收集器。这里的“单线程”的意义,既是指它只会使用一个CPU和一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停所有用户线程。
虽然Serial收集器单线程的特点造成用户体验很差,但是它也有巨大的优点,简单而高效。尤其对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集可以获得最高的单线程收集效率。目前Serial收集器仍是桌面级Client模式下虚拟机的默认新生代收集器。
简而言之,缺点是会造成停顿,优点是简单高效,适合桌面级Client模式。
2. ParNew收集器(复制算法)
ParNew收集器实际上就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为(包括Serial收集器可用的所有控制参数、收集算法、Stop the World、对象分配规则、回收策略等)都与Serial收集器完全一样。
ParNew收集器是许多Server模式下的虚拟机中首选的新生代收集器,其中一个重要原因是除了Serial收集器外,目前只有ParNew收集器可以和老年代的CMS收集器配合。
ParNew在单CPU情况下,由于存在线程切换的开销,哪怕在两个CPU的情况下效率都有可能比Serial更低;但是随着CPU数量的增加,ParNew对GC时系统资源的有效利用也回来越好。
3. Parallel Scavenge收集器(复制算法)
Parallel Scavenge收集器是新生代多线程收集器。Parallel Scavenge 收集器关注点是吞吐量(⾼效率的利⽤CPU),所谓吞吐量就是CPU中⽤于运⾏⽤户代码的时间与CPU总消耗时间的⽐值。CMS等垃圾收集器的关注点更多的是⽤户线程的停顿时间(提⾼⽤户体验)。 Parallel Scavenge收集器提供了很多参数供⽤户找到最合适的停顿时间或最⼤吞吐量。
4、Serial Old收集器(标记-整理算法)
Serial收集器的老年代版本,也是单线程收集器,使用标记-整理算法。 这个收集器主要也是给Client模式下的虚拟机使用。
如果在Server模式下,还有两大用途:一种是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配;一种是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
5. Parallel Old收集器 (标记-整理算法)
Parallel Scavenge收集器的⽼年代版本。使⽤多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,优先考虑Parallel Scavenge收集器+ Parallel Old收集器的组合。
6. CMS收集器(标记-清除算法)
CMS(Concurrent Mark Sweep)收集器是一款以获取最短回收停顿时间为目标的收集器。目前很大一部分应用集中在互联网站或者B/S系统服务器上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短。
CMS收集器的运作过程分为4步:
1)初始标记:需要“Stop the World”,用于标记GC Roots能直接关联的对象,速度很快;
2)并发标记:不需要“Stop the World”,进行GC Roots Tracing,从GC Roots开始找到它能引用的所有其它对象。
3)重新标记;需要“Stop the World”,修正并发标记期间因用户继续运作而导致标记产生变动的那一部分对象的标记记录,停顿时间略长于初始标记,但远远短于并发标记的时间;
4)并发清除:不需要“Stop the World”。开启⽤户线程,同时GC线程开始对为标记的区域做清扫。
由于整个过程中耗时最长的并发标记和并发清除过程,收集器线程都可以与用户线程并发工作,所以,总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的缺点:
1)CMS对CPU资源非常敏感:
在并发阶段,CMS虽然不会导致用户线程停顿,但是还会因为占用了部分CPU资源导致应用程序变慢,总吞吐量降低。
2)可能出现“Concurrent Mode Failure”且CMS无法处理浮动垃圾。
CMS必须预留一部分空间提供给并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会触发“Concurrent Mode Failure”,虚拟机将启动后备预案,临时启用Serial Old收集器,但停顿时间会明显增加。
浮动垃圾,指的是并发清理阶段产生的垃圾。因为并发清理阶段用户程序也在运行,产生的垃圾在标记过程之后,所以本次清理过程不会被清理,
3)基于标记-清除算法,收集结束会产生大量空间碎片:
主要优点:并发收集、低停顿。
三个明显的缺点:
对CPU资源敏感;⽆法处理浮动垃圾;使⽤的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣。
7、G1收集器
G1收集器是一款面向服务端应用的收集器,它能充分利用多CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。
在G1中分代概念仍然保留。虽然G1不需要和其他收集器配合,可以独立管理GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。
G1将内存分成多个大小相等的独立区域,虽然还保留着新生代和老年代的概念,但是新生代和老年代不再是物理隔离的,它们都是一部分Region的集合。
G1收集器的优点:
1)空间整合:
G1从整体看是基于标记-整理算法实现的收集器,从局部看是基于复制算法。这两种算法都意味着G1运行期间不会产生大量内存空间碎片。
2)可预测的停顿:
降低停顿时间是G1和CMS共同关注的,但G1能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,GC的时间不得超过N毫秒。
3)有计划的垃圾回收:
G1可以有计划的在Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆的价值大小(回收所获得的空间大小,以及回收所需要的时间),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这就Garbage-First的由来。
25. 类加载过程
类加载过程是指JVM虚拟机把.class文件中类信息加载进内存,并进行解析生成对应的class对象的过程。
举个通俗点的例子来说,JVM在执行某段代码时,遇到了class A, 然而此时内存中并没有class A的相关信息,于是JVM就会到相应的class文件中去寻找class A的类信息,并加载进内存中,这就是我们所说的类加载过程。
由此可见,JVM不是一开始就把所有的类都加载进内存中,而是只有第一次遇到某个需要运行的类时才会加载,且只加载一次。
类加载的过程主要分为三个部分:加载;链接;初始化。
而链接又可以细分为三个小部分:验证、准备、解析。
加载:把class字节码文件从各个来源通过类加载器装载入内存中。
字节码来源:一般的加载来源包括从本地路径下编译生成的.class文件,从jar包中的.class文件,甚至是网络数据源等;如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。
类加载器:一般包括启动类加载器,扩展类加载器,应用类加载器,以及用户的自定义类加载器。
问:为什么会有自定义类加载器?
一方面是由于java代码很容易被反编译,如果需要对自己的代码加密的话,可以对编译后的代码进行加密,然后再通过实现自己的自定义类加载器进行解密,最后再加载。
另一方面也有可能从非标准的来源加载代码,比如从网络来源,那就需要自己实现一个类加载器,从指定源进行加载。
验证:保证加载进来的字节流符合虚拟机规范,不会造成安全错误。 包括对于文件格式的验证,对于元数据的验证,对于字节码的验证,对于符号引用的验证等。
准备:为类变量(注意,不是实例变量)分配内存,并且赋予初值。
初值,不是代码中具体写的初始化的值,而是Java虚拟机根据不同变量类型的默认初始值。比如8种基本类型的初值,默认为0;引用类型的初值则为null;常量的初值即为代码中设置的值,final static tmp = 456, 那么该阶段tmp的初值就是456
解析:将常量池内的符号引用替换为直接引用。
在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。
符号引用:即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息。
直接引用:可以理解为一个内存地址,或者一个偏移量。
举个例子来说,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。
初始化:对类变量初始化,是执行类构造器的过程。换句话说,只对static修饰的变量或语句进行初始化。
如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
类加载过程只是一个类生命周期的一部分,在其前,有编译的过程,只有对源代码编译之后,才能获得能够被虚拟机加载的字节码文件;在其后还有具体的类使用过程,当使用完成之后,还会在方法区垃圾回收的过程中进行卸载。
26. 类加载器与双亲委派模型
类加载器
JVM 中内置了三个重要的 ClassLoader:
1. BootstrapClassLoader(启动类加载器) :
最顶层的加载类,由C++实现,负责加载%JAVA_HOME%/lib ⽬录下的jar包和类或者或被 -Xbootclasspath 参数指定的路径中的所有类。
2. ExtensionClassLoader(扩展类加载器) :
主要负责加载⽬录 %JRE_HOME%/lib/ext ⽬录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
3. AppClassLoader(应⽤程序类加载器) :
⾯向⽤户的加载器,负责加载当前应⽤classpath下的所有jar包和类。
除了 BootstrapClassLoader, 其他类加载器均由 Java 实现且全部继承⾃ java.lang.ClassLoader 。
双亲委派模型
在类加载的时候,系统会⾸先判断当前类是否被加载过。已经被加载的类会直接返回,若没有加载则调用⽗类加载器的loadClass()方法,若⽗类加载器为空则默认使用启动类加载器作为⽗类加载器,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父类加载失败,抛出ClassNotFoundException异常后,子加载器才会尝试自己调用findClass()方法进行加载。
优点:
双亲委派模型是Java加载类的机制。采用双亲委派模型的好处:
- Java类随着它的类加载器一起具备了一种带有优先级的层级关系,通过这种层级关系可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类)。
- 保证了 Java 的核心 API 不被篡改。比如用户自己编写一个称为 java.lang.Object 类,有双亲委派模型的话,是由启动类加载器进行加载,自己编写的Object类不会被启动类加载器进行加载,不会出现混乱。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将发生混乱。
双亲委派模型的破坏
第一次:由于双亲委派模型在JDK 1.2之后才被引入,而类加载器和抽象类java.lang.ClassLoader则在JDK 1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。
第二次:因为线程上下文类加载器。有了线程上下文类加载器,就可以使父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则
第三次:由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是:代码热替换(HotSwap)、模块热部署(Hot Deployment)等。
如果不想⽤双亲委派模型怎么办?
为了避免双亲委托机制,可以⾃⼰定义⼀个类加载器,然后重载 loadClass() 即可。
如何⾃定义类加载器?
除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承⾃java.lang.ClassLoader 。如果我们要⾃定义⾃⼰的类加载器,很明显需要继承 ClassLoader 。