1 JVM的内部结构是怎样的?
JVM结构分为三部分:类加载器子系统、运行时数据区、执行引擎。
类加载器子系统:负责把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化。
运行时数据区:JVM在执行Java程序时管理的内存区域。
执行引擎:负责执行代码(输入字节码二进制流,输出执行结果)。
1.1 说说运行时数据区。
运行时数据区包括:堆、方法区、虚拟机栈、本地方法栈、程序计数器。
堆:所有线程共享,在虚拟机启动时创建,唯一的用途是存放对象实例,Java中几乎所有的对象实例都在这里分配内存。
方法区:所有线程共享,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
虚拟机栈:线程私有,每个方法被执行的时候,Java虚拟机会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,每一个方法被调用直至执行完毕的过程,对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
本地方法栈:线程私有,与虚拟机栈的区别是,虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用到的本地(Native)方法服务。
程序计数器:线程私有,记录当前线程执行的字节码的行号,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。
1.2 Java堆内存是怎么划分的?
虚拟机设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。空间大小Young : Old = 1 : 2。
将Java堆内存划分为新生代和老年代主要依据分代收集理论:
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
新生代又可以划分为一块较大的Eden空间和两块较小的Survivor空间(From Survivor和To Survivor)。空间大小Eden : From : To = 8 : 1 : 1。
1.2.1 每代用什么垃圾回收算法回收?
新生代:标记-复制算法。
老年代:标记-清除算法或者标记-整理算法。
标记-复制算法:
给对象分配内存只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
罕见情况下,另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便通过分配担保机制直接进入老年代。
不适用场景:对象的存活率较高时不适用。
标记-清除算法:
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
缺点:空间碎片化严重。
标记-整理算法:
在标记完成后,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界(指针)之外的内存。
缺点:效率不高。
1.2.2 怎么标记需要回收的对象?
通过可达性分析算法判定对象是否存活。这个算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能再被使用的。
在Java技术体系里面,固定可作为GC Roots的对象包括:
在虚拟机栈中引用的对象,如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
在方法区中类静态属性引用的对象,如Java类的引用类型静态变量。
在方法区中常量引用的对象,如字符串常量池中的引用。
在本地方法栈中Native方法引用的对象。
Java虚拟机内部的引用,如基本数据类型对于的Class对象、一些常驻的异常对象、还有系统类加载器。
所有被同步锁持有的对象。
等等。
1.3 你知道哪些垃圾回收器?
Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1。
一般是一个新生代收集器搭配一个老年代收集器使用。常用搭配:
Serial - Serial Old:新生代采取复制算法,老年代采取标记-整理算法。
Parallel Scavenge - Parallel Old:新生代采取复制算法,老年代采取标记-整理算法。特点是吞吐量优先。
ParNew - CMS:新生代采取复制算法,老年代采取标记-清除算法。响应时间优先。
G1(单独使用):面向局部收集、基于Region,采取复制算法。
1.3.1 CMS收集器做垃圾回收的流程是怎样的?
1. 初始标记:(暂停用户线程)仅仅标记一下GC Roots能直接关联到的对象,速度很快。
2. 并发标记:从GC Roots的直接关联对象开始遍历整个对象图,找出要回收的对象,这个过程耗时较长,但可以与用户线程并发运行。
3. 重新标记:(暂停用户线程)修正并发标记期间,因用户程序继续运作而导致标记变动的那一部分对象的标记记录。
4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,这个阶段也是可以与用户线程并发的。
1.3.2 G1收集器又是怎样工作的?
G1不再坚持固定大小以及固定数量的分代区域,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
G1收集器会去跟踪各个Region里面的垃圾堆积的价值大小,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。
G1收集器的运作过程大致可划分为:
1. 初始标记:(暂停用户线程)仅仅标记一下GC Roots能直接关联到的对象,耗时很短。
2. 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
3. 最终标记:(暂停用户线程)处理在并发阶段发生引用变动的对象。
4. 筛选回收:(暂停用户线程)更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧region的全部空间。
1.3.3 G1什么时候会引发Full GC?
在并发标记阶段,用户线程还在继续进行,G1的每个region都需要划分一部分空间用于并发回收过程中新对象的分配。如果内存回收的速度赶不上内存分配的速度,G1收集器会被迫冻结用户线程的执行,导致Full GC。
1.3.4 把Java堆分成多个独立Region后,Region里面存在的跨Region引用对象要如何解决?
G1的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。垃圾收集发生时,把它们加入GC Roots中一并扫描。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
卡表是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表在HotSpot虚拟机中以字节数组的形式实现,字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小(HotSpot中为512字节)的内存块,这个内存块被称作“卡页”。
一个卡页的内存中通常包含不止一个对象,只要卡页内有对象的字段存在跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页中包含跨代指针,把它们加入GC Roots中一并扫描。
1.4 什么是ThreadLocal?底层是如何实现的?
ThreadLocal是给每个线程在Java堆中分配的一小块内存,其中的对象只能被这个线程获取,不允许其它线程访问,即ThreadLocal具有线程隔离的效果。
2 内存溢出和内存泄漏分别指什么?
内存溢出:内存不够用。
内存泄漏:内存无法释放。
2.1 写程序测试栈溢出、堆溢出。
栈溢出:(无限调用方法)
/**
* VM Args:-Xss128k
* @author zzm
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
堆溢出:(无限创建对象)
/**
* VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* @author zzm
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
3 能不能讲讲对象的创建过程?
1. 当JVM执行到一条字节码new指令时,首先会去检测待创建对象对应的类是否已经被加载,如果没有,必须先执行相应的类加载过程。
2. 类加载检查通过后,JVM会为新生对象分配内存。
3. 内存分配完成后,虚拟机将分配到的内存空间(除了对象头)都初始化为0,保证对象的实例字段在Java代码中可以不赋初始值就直接使用。
4. 接下来,JVM还要设置对象的内部(头部)信息。
5. 最后,执行构造函数,按程序员的意愿对对象进行初始化。
3.1 JVM是怎么给对象分配内存的?
对象所需内存的大小在类加载完成后便可以完全确定,为对象分配空间实际上就是把一块大小确定的内存块从Java堆中划分出来。
假如Java堆中内存是绝对规整的,所有被使用过的内存被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把指针向空闲空间方向移动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
假如Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
选择哪种分配方式是由Java堆是否规整决定的,而Java堆是否规整又是由所采用的垃圾收集器是否带有空间压缩整理的能力决定的。
当使用Serial、ParNew等带压缩 整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效,而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
除了如何划分可用空间外,JVM还要考虑对象创建在并发情况下的线程安全问题。解决这个问题有两种可选方案:
一种是对分配内存空间的动作进行同步处理——CAS+失败重试,保证更新操作的原子性。
另一种是给每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
3.1.1 对象分配内存的大小是多少?
默认情景:64位虚拟机、普通Java对象、开启指针压缩。
如果实例数据占用内存为0,整个对象共占用16字节的内存。
其中,对象头占12字节(Mark Word占8字节,类型指针占4字节),对齐填充占4字节。
32位虚拟机、普通Java对象:
对象头占8个字节,其中4个字节是Mark Word,4个字节是类型指针。
实例数据占用内存不确定。
对齐填充保证对象的大小是8字节的整数倍,若实例数据占用内存为0,对齐填充占用内存也为0。
32位虚拟机、数组对象:
对象头占12个字节,其中4个字节是Mark Word,4个字节是类型指针,4个字节是数组长度。
实例数据占用内存不确定。
对齐填充保证对象的大小是8字节的整数倍,若实例数据占用内存为0,对齐填充占用内存为4字节。
64位虚拟机、普通对象、未开启指针压缩:
对象头占16个字节,其中8个字节是Mark Word,8个字节是类型指针。
实例数据占用内存不确定。
对齐填充保证对象的大小是8字节的整数倍,若实例数据占用内存为0,对齐填充占用内存也为0。
64位虚拟机、数组对象、未开启指针压缩:
对象头占20个字节,其中8个字节是Mark Word,8个字节是类型指针,4个字节是数组长度。
实例数据占用内存不确定。
对齐填充保证对象的大小是8字节的整数倍,若实例数据占用内存为0,对齐填充占用内存为4字节。
64位虚拟机、数组对象、开启指针压缩:
对象头占16个字节,其中8个字节是Mark Word,4个字节是类型指针,4个字节是数组长度。
实例数据占用内存不确定。
对齐填充保证对象的大小是8字节的整数倍,若实例数据占用内存为0,对齐填充占用内存也为0。
3.1.2 Object obj = new Object(); 运行这行代码要分配多少内存给对象?
默认情景:64位虚拟机、普通Java对象、开启指针压缩。
20字节。其中,对象new Object()占用16字节(堆内存),引用obj占用4字节(栈内存)。
32位虚拟机:引用占4个字节。
64位虚拟机、未开启指针压缩:引用占8个字节。
64位虚拟机、开启指针压缩:引用占4个字节。
3.2 对象内部信息具体包括什么内容?
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头部分包括两类信息:Mark Word和类型指针(Klass Pointer)。
Mark Word:对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。
实例数据:对象真正存储的有效信息,即程序代码里面定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录下来。
对齐填充:并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。保证对象的大小是8字节的整数倍。
3.2.1 能不能详细说说Mark Word。
Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
对象未被锁定的正常状态下:对象HashCode、GC分代年龄、偏向模式标志位(为0)、锁标志位(为01)。
可偏向状态下:偏向线程ID、偏向时间戳、GC分代年龄、偏向模式标志位(为1)、锁标志位(为01)。
轻量级锁定状态下:调用栈中锁记录的指针、锁标志位(为00)。
重量级锁定状态下:指向重量级锁的指针、锁标志位(为10)。
GC标记状态下:锁标志位(为11)。
分配对象时,若虚拟机没有启动偏向锁,对象进入未被锁定的正常状态,若虚拟机启动了偏向锁,对象进入可偏向状态。
假设虚拟机启动了偏向锁,当锁对象第一次被线程获取的时候,虚拟机会把对象头中的锁标志位设置为01、把偏向模式标志位设置为1,表示进入偏向模式,同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当一个线程要对此对象上同步锁的时候,虚拟机首先将在该线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新操作成功了,代表该线程拥有了这个对象的锁,对象Mark Word的锁标志位转变为00,表示此对象处于轻量级锁定状态。如果这个更新操作失败了,就意味着至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,程序继续执行,否则就说明这个锁对象已经被其它线程抢占了。
如果出现两条以上的线程争用同一把锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志位变为10,此时Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也必须进入阻塞状态。
4 讲讲类加载机制呗。
Java虚拟机中类加载的全过程包括加载、验证、准备、解析和初始化五个阶段。
1. 加载阶段,Java虚拟机需要完成:
通过一个类的全限定名来获取定义此类的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
2. 验证阶段,确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
3. 准备阶段,正式为类中定义的变量分配内存并设置类变量初始值。
4. 解析阶段,Java虚拟机将常量池内的符号引用替换为直接引用的过程。
5. 初始化阶段,根据程序员通过程序编码指定的主观计划去初始化类变量和其它资源。
4.1 JVM都有哪些类加载器?
启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA_HOME>\lib目录,而且是Java虚拟机能够识别的类库加载到虚拟机的内存中。
扩展类加载器(Extension ClassLoader),负责加载<JAVA_HOME>\lib\ext目录中所有的类库。
应用程序类加载器(Application ClassLoader),负责加载用户类路径(ClassPath)上所有的类库。
4.2 Tomcat、Jetty、WebLogic等服务器为什么要重写类加载器?
一个功能健全的Web服务器,都要解决如下问题:
部署在同一个服务器上的两个Web应用程序所使用的Java类库既要实现相互隔离,又要实现互相共享。
服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响,服务器所使用的类库应该与应用程序的类库互相独立。
支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能(热替换)。
各种Web服务器的做法是:提供好几个有着不同含义的ClassPath路径供用户存放第三方类库,并且自定义了多个类加载器。
这样的做法没有打破双亲委派模型,自定义类加载器都是Application类加载器的子孙加载器。
5 请介绍一下Java内存模型。
Java内存模型的主要目的是定义程序中各种变量的访问规则:
Java内存模型规定了所有的变量都存储在主内存中。
每条线程有自己的工作内存,保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。
不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
5.1 内存间的8个原子操作指令是哪些?
lock、unlock、read、load、use、assign、store、write。
锁定、解锁、读取、载入、使用、赋值、存储、写入。