JVM-攻城掠地

486 阅读10分钟

笔者最近在面试时经常会被问道JVM及其GC相关方面的问题,在此做一下总结!

作为互联网公司必问的问题之一,习惯性的问题有哪些: 简述JVM的分区? 对象存亡? GC算法? jvm内存模型? 类加载的过程? 类加载机制?
本文相关描述将参考《深入理解Java虚拟机》。
1、运行时数据区域?

可以按照对线程的状态分为
线程私有如:程序计数器、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)
线程公有如:堆(Heap)、方法区

程序计数器:它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作就是通过改变计数器的值来选取吓一跳需要执行的字节码指令(如分支、循环、跳转、异常处理、线程恢复)。
Java虚拟机栈:它的生命周期与线程相同,为虚拟机执行Java方法(字节码)服务。其描述的是Java方法执行的内存模型:每个方法在执行的时候会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表中存放了编译期可知的各种基本数据类型、对象引用、returnAddress类型。 (局部变量所需的内存空间在编译期间完成分配)
本地方法栈:为虚拟机使用到的Native方法服务。

Java堆:各个线程共享的一块的内存,在虚拟机启动的时候创建,用于存放实例对象。
堆是垃圾收集器管理的主要区域,因很多时候又称做“GC堆”。
方法区:各个线程共享的内存区域,用于存储已被虚拟机加载的类的信息、常量、静态变量、即时编译后的代码等数据。 有时候也称为永久代,但并非数据进入方法区就永久存在。该区域回收的主要目标是针对常量池的回收和对类型的卸载。

补充:
运行时常量池:存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
直接内存:并非虚拟机运行时数据区的一部分,如果频繁进行NIO操作,使得各个内存区域总和大于物理内存限制,导致动态扩展时出现OutOfMemoryError异常。
内存溢出的关键是:垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但是Direct Memory却不能像新生代、老年代那样,发现空间不足就通知收集器进行垃圾回收,它只能等待老年代满了之后Full GC,顺便清理掉内存的废弃对象。否则只能等到抛出内存溢出异常时,catch掉“执行”System.gc(),但是只是通知虚拟机执行,并非一定执行,所以虚拟这时候可能会出现堆中存在空闲内存,但却出现内存溢出的情况。

2、对象的创建与消亡?
创建:当虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有则执行相应的类加载的过程,并给新生对象分配内存。
判断一个对象是否处于空闲状态?两种分配方式
指针碰撞:指针作为分界点的指示器,区分空闲区和非空闲区。(内存规整) 空闲列表:虚拟机维护的列表,做记录和更新操作。(不规整,相互交错)
对象内存布局?
对象头(第一部分:存储对象自身的运行时数据;第二部分:类型指针(对象指向它的类元数据的指针,虚拟机通过指针判断是哪个类的实例)。)
实例数据(对象真正有效的信息)
对齐填充 (无真实含义,做为占位符存在。)

判断是否存活的两种方法?
1、引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就增加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能在被使用了。(Java虚拟机并未采用引用计数算法原因:很难解决对象之间相互循环引用的问题)
2、可达分析算法
基本思路就是通过一些列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法去中常量引用的对象。
本地方法栈JNI(即一般说的Native方法)引用的对象。

即使在可达分析算法中的不可达对象,也并非是非死不可的,相当于处于缓刑阶段,至少经历两次标记过程,才会宣告一个对象的死亡:如果对象在进行可达分析算法后发现没有与GC Roots相连接的引用链,将会第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果该对象有必要执行finalize()方法,那么这个对象将会防止在一个F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。但并不会承诺等待它的运行结束,finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次标记。没逃脱话意味着需要进行回收,逃脱意味着需要跟引用链对象关联。

3、垃圾收集算法
1、标记清除算法
算法分为标记和清除两个阶段:首先标记出需要回收的对象,在标记完成后统一回收所有被标记的对象。
两个不足:一个是效率问题,标记和清除两个过程效率都不高;另一个是空间问题,标记清除之后会产生大连续的内存碎片,空间碎片太多可能导致以后程序运行过程中需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
2、复制算法
解决了效率问题。它可将内存按容量划分为大小相等的两块,每次只是用其中的一块。当这一块内存用完了,将还存活着的对象复制到另一块上面,然后把使用过的内存空间一次清理掉。这样每次都是整个半区域进行内存回收,内存分配时不需要考虑内存碎片的情况,只需要移动堆顶指针按照顺序分配内存即可。
不足:代价高,将内存缩小为原来的一半。
改进:内存去不再按照1:1的比例来划分内存空间,二是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中海存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才Eden和刚才使用过的空间。当Survivor空间不够用时,需要依赖其他内存进行分配担保。如果另一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象直接通过分配担保机制进入老年代。 新生代特点需满足“朝生夕死”。
默认Eden:Survivor = 8:1 即默认新生代占90% ((1+8)/10 )
3、标记整理算法
标记整理与标记清除算法一样,但后续步骤不是直接对可回收对象进行整理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
4、分代回收算法 (商业虚拟机) 根据对象存活周期不同将内存划分为几块。
新生代:垃圾回收时有大批对象死去,只有少量存活。使用复制算法 老年代:存活率高、没有额外的空间分配担保,所以使用标记清理或者标记整理算法进行回收。

4、Java内存模型(JMM)
Java内存模型主要目标是定义程序中各个变量的访问规则,即虚拟机中将变量存储到内存和从内存中读取出变量这样的底层细节。 Java内存模型规定了所有变量都存储在主内存中,每条程序还有自己的工作内存,线程的工作内存保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有(读、写、赋值)操作都必须在工作内存中进行,而不能直接读写主内存中变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

在执行程序的时,为提高性能,编译器和处理器常常会对指令重排序。分三种类型:
1)编译器优化重排序。
2)指令级并行重排序。
3)内存系统的重排序。

源代码从编译到执行要经过的指令序列包含以上三个步骤,重排序可能导致多线程程序出现内存可见性的问题。 对于处理重排序,JMM的处理器排序规则会要求Java编译器生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

补充:
对此volatile是个很好学习例子,以后再单独写文章赘述。
简单说下其的两个主要特性:
第一保证所修饰的变量对有线程对其的可见性,第二语义是禁止指令重排序优化。
其有些特定场合并不适用,比如运算结果依赖当前值,或有其它状态变量参与。具体事例可以参考volatile实现单例模式的双重检查机制。
这里不可不说的是双重检查机制中为啥要用?
instance = new Singleton()
具体实现分三个过程: 1)分配对象内存空间。
2)初始化对象。
3)设置instance指向分配的内存地址。
在2、3可能会重排序。

接下来要说就是JMM围绕并发过程中如何处理原子性、可见性和有序性?(这里我会单独写并发处理的文章来赘述。)

5、常用命令
jps 显示系统指定的所有HotSpot虚拟机进程。
jstat 用于收集HotSpot虚拟机各方面运行数据。
jinfo 显示虚拟机配置信息。
jmap 生成虚拟机内存快照。
jhat 分析heapdump文件。建立一个Http/html服务器用户可以在浏览器上查看分析结果。
jstack 虚拟机线程快照。

常用工具:JConsole、VisualVM (可以在IDEA、Eclipse中配置相关插件,Eclipse Memory Analysis等)

未完待续