Java虚拟机及GC基础介绍

作为一个hadoop开发,工作中难免会遇到gc的问题,以前总是一头雾水,这次抽时间系统的整理下,只是入门,更加深入的知识在工作中用到了再整理。

要理解gc先要搞清楚JVM,JVM是一个抽象的计算机结构。Java程序根据JVM的特性编写。JVM针对特定于操作系统并且可以将Java指令翻译成底层系统的指令并执行。JVM确保了Java的平台无关性。

JVM架构

下图是JVM的架构(此架构图是HotSpot JVM)
JVM架构图
下面简单介绍下各个组件:

class files文件是通过.java文件编译而来的。

ClassLoader的作用是装载能被JVM识别的指令,也就是加载.class文件到堆中,形成一个该类的对象(也就是说ClassLoader将class文件翻译为对象存放在堆内存中。)。其加载流程为加载 –> 验证 –> 准备 –> 解析 –> 初始化
ClassLoader,这个东西还是非常重要的,在JVM中是通过ClassLoader和类本身共同去判断两个Class是否相同。换句话说就是:不同的ClassLoader加载同一个Class文件,那么JVM认为他们生成的类是不同的。有些时候不会从Class文件中加载流(比如Java Applet是从网络中加载),那么这个ClassLoader和普通的实现逻辑当然是不一样的,通过不同的ClassLoader就可以解决这个问题。

Runtime Data Area(运行数据区域)也是Java内存区域,在此区域内又分为5部分,GC就是对这些区域内存的操作。其中这5部分根据是否被线程共享可以分为两大类,PC Register、Java Stacks 和 Native Method Statck为线程私有的,Heap Memory 和 Method Area是线程共享的

1、PC Register(程序计数器)

PC Register是一个比较小的内存区域,用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。
每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。
如果程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写完成)方法,则计数器的值为Undefined,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器是JVM内存区域中唯一一个不会发生OutOfMemoryError的区域

2、Java Stacks(Java 栈)

Java栈也称为方法栈,存放这与方法相关的信息,如方法中的局部变量、方法入口、方法出口等。
一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在Java栈中入栈,当方法执行完成时,栈帧出栈

局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有long和double类型会占用2个局部变量空间(Slot,对于32位机器,一个Slot就是32个bit),其它都是1个Slot。需要注意的是,局部变量表是在编译时就已经确定好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

Java栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度(例如递归的深度较大),则抛出StatckOverFlowError(栈溢出);不过多数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出OutOfMemoryError(内存溢出)。

Java栈也是一个线程对应着一个Java栈,因此Java栈也是线程私有的。

3、Native Method Statck(本地方法栈)

本地方法栈在作用,运行机制,异常类型等方面都与Java栈相同,唯一的区别是:Java栈是执行Java方法的,而本地方法栈是用来执行native方法(也就是由c语言编写的方法)的,在很多虚拟机中(如Sun的JDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。
本地方法栈也是线程私有的。

4、Heap Memory(堆内存)

堆内存主要是用来动态的存储对象实例,原则上讲,所有的对象都在堆区上分配内存(不过现代技术里,也不是这么绝对的,也有栈上直接分配的)。也是Java GC机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区的存在是为了存储对象实例,

一般的,根据Java虚拟机规范规定,堆内存需要在逻辑上是连续的(在物理上不需要),在实现时,可以是固定大小的,也可以是可扩展的,目前主流的虚拟机都是可扩展的。如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java heap space异常

堆内存的详细内存在下面的章节进行扩展。

5、Method Area(方法区)

方法区也就是平时所说的非堆内存(Non-heap),但按照Java GC的分代收集机制来说,方法区也叫永久代(Permanent Generation)。

方法区是各个线程共享的区域,用于存储已经被虚拟机加载的类信息(即加载类时需要加载的信息,包括版本、field、方法、接口等信息)、final常量静态变量、编译器即时编译的代码等。

方法区在物理上也不需要是连续的,可以选择固定大小或可扩展大小,并且方法区比堆还多了一个限制:可以选择是否执行垃圾收集。一般的,方法区上执行的垃圾收集是很少的,这也是方法区被称为永久代的原因之一(HotSpot),但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载

在方法区上进行垃圾收集,条件苛刻而且相当困难,效果也不令人满意,所以一般不做太多考虑,可以留作以后进一步深入研究时使用。

方法区在内存不足时抛出OutOfMemoryError:PermGen space异常,。

Execution Engine(执行引擎)的功能是执行classes中的指令。任何JVM specification实现(JDK)的核心都是Execution engine,而Execution engine最核心的两块是JIT(just in time)即时编译器和GC(Garbage Collection)垃圾回收器。Execution Engine包含Interpreter(解释器)、JIT、Hotspot profiler和GC。

Interpreter:主要功能是读bytecode,然后执行相对应的指令。
JIT(Just-in-time) Compiler:这个部分主要是用以优化execution engine的性能,一般execution engine会先用interpreter去解释bytecode, 然后执行对应的命令。在很多时候,JIT compliler可以将相类似的bytecode都翻译成native code,然后直接运行。直接执行native code会比bytecode快。
Hotspot profiler: 这个部分也是用来优化性能的,当某些method被多次使用时,Hotspot这个部分就会用profiler去记录那些method所对应的native code, 这样就可以直接用native code而不是byte code了。
Garbage collector: 这个部分主要负责内存的管理,当程序中的object不再被用的时候,garbage collector会将其删除。

通过类装载器装载的,被分配到JVM的运行时数据区的字节码会被执行引擎执行。执行引擎以指令为单位读取Java字节码。它就像一个CPU一样,一条一条地执行机器指令。每个字节码指令都由一个1字节的操作码和附加的操作数组成。执行引擎取得一个操作码,然后根据操作数来执行任务,完成后就继 续执行下一条操作码。
不过Java字节码是用一种人类可以读懂的语言编写的,而不是用机器可以直接执行的语言。因此,执行引擎必须把字节码转换成可以直接被JVM执行的语言。字节码可以通过以下两种方式转换成合适的语言。

  • 解释器(解释执行):一条一条地读取,解释并且执行字节码指令。因为它一条一条地解释和执行指令,所以它可以很快地解释字节码,但是执行起来会比较慢。这是解释执行的语言的一个缺点。字节码这种“语言”基本来说是解释执行的。

  • 即时(Just-In-Time)编译器(编译执行): 即时编译器被引入用来弥补解释器的缺点。执行引擎首先按照解释执行的方式来执行,然后在合适的时候,即时编译器把整段字节码编译成本地代码。然后,执行引 擎就没有必要再去解释执行方法了,它可以直接通过本地代码去执行它。执行本地代码比一条一条进行解释执行的速度快很多。编译后的代码可以执行的很快,因为 本地代码是保存在缓存里的。

不过,用JIT编译器来编译代码所花的时间要比用解释器去一条条解释执行花的时间要多。因此,如果代码只被执行一次的话,那么最好还是解释执行而不是编译后再执行。因此,内置了JIT编译器的JVM都会检查方法的执行频率,如果一个方法的执行频率超过一个特定的值的话,那么这个方法就会被编译成本地代码

垃圾回收(GC)

垃圾回收主要组件是堆内存和垃圾回收器。堆内存是内存数据区,用来保存运行时的对象实例。垃圾回收器也会在这里操作。垃圾回收器是个守护进程。

GC主要是对堆内存的回收,堆内存按照隔代划分将堆内存划分为新生代(Young Generation)、老年代(Old Generation),方法区(non-heap)划分为永久代(Permanent Generation)。java的内存分配如下图:
java内存分配

  • 新生代

新生代分为3个区域:Eden、Survivor0和Survivor1。

新创建的对象被分配在Eden区(大对象可以直接被创建在年老代),由于大部分对象在创建后会很快不再使用,所以很多对象被创建在新生代,然后消失。对象从这个区域消失的过程我们称之为minor GC(or young gc)

新生代minor gc的流程为:
1、新生成的对象放入Eden区,等Eden区满之后,执行一次minor gc,将Eden中存活下来的对象放入S0(Survivor0)中
2、此时Eden区为空,新生的对象再次放入Eden区,Eden区再次被放满,执行一次minor gc,此时将Eden中存活的对象再次放入S0中,循环几次待S0也满之后,将Eden和S0中存活的对象放入S1中,此时Eden和S0为空
3、反复上述流程15次(默认15次,由-XX:MaxTenuringThreshold控制,大于该值进入老年代,但这只是个最大值,并不代表一定是这个值)之后,将S0或者S1中依然存活的对象放入老年代中,将Eden中存活的对象放入S0或者S1中。

S0和S1两个幸存区同时只有一个区域里有数据

Eden、S0和S1所占的比例由参数-XX:SurvivorRatio控制,默认SurvivorRatio=8,即Eden:S0:S1=8:1:1。如果SurvivorRatio=10,Eden:S0:S1=10:1:1,Eden占新生代的10/12,S0和S1各占1/12。

在执行minor gc时可能需要查询整个老年代以确定新生代中的对象是否可以清理回收,这显然是低效的。解决的方法是,老年代中维护一个card table,他是一个512 byte大小的块。所有老年代对象引用新生代对象的记录都记录在这里。当针对新生代执行GC的时候,只需要查询card table来决定是否可以被回收,而不用查询整个老年代。这个card table由一个write barrier来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但GC的整体时间被显著的减少。

  • 老年代

对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次Young GC后存活了下来),则会被复制到老年代,老年代的空间一般比新生代大,能存放更多的对象,在老年代上发生的GC次数也比新生代少。当老年代内存不足时,将执行Major GC,也叫Full GC

他们是一个概念,就是针对老年代/永久代进行GC。因为取名叫Full就会让人疑惑,到底会不会先Minor GC。事实上Full GC本身不会先进行Minor GC我们可以配置,让Full GC之前先进行一次Minor GC,因为老年代很多对象都会引用到新生代的对象,先进行一次Minor GC可以提高老年代GC的速度。比如老年代使用CMS时,设置CMSScavengeBeforeRemark优化,让CMS remark之前先进行一次Minor GC

个人理解Major GC针对Old区,此区域的gc算法包括CMS、G1等。而Full GC的次数是由STW(stop the world)决定的,则当使用CMS(initial mark、concurrent mark、remark和concurrent sweep)时,对Old区进行gc时,full gc的个数会加2,因为CMS中STW的次数是2(分别为initial mark和remark阶段)

CMS 不等于Full GC,我们可以看到CMS分为多个阶段,只有stop the world的阶段被计算到了Full GC的次数和时间,而和业务线程并发的GC的次数和时间则不被认为是Full GC

新生代和老年代所占内存的比例参数由-XX:NewRatio控制,默认NewRatio=3,即Old:Young=3:1,则Old占内存的3/4,Young占内存的1/4。

  • 永久代

永久代用来保存类常量以及字符串常量。因此,这个区域不是用来永久的存储那些从老年代存活下来的对象。这个区域也可能发生GC。并且发生在这个区域上的GC事件也会被算为major GC

运行时常量池(Runtime Constant Pool)是存在该区域,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量(比如String类的intern()方法,作用是String维护了一个常量池,如果调用的字符“abc”已经在常量池中,则返回池中的字符串地址,否则,新建一个常量加入池中,并返回地址)。

GC监控

GC监控主要是为了搞清楚JVM是如何执行GC的,鉴别JVM是否在高效地执行GC,以及是否有必要进行额外的性能调优。

监控GC有很多种方法,这里主要介绍两种,一种是图形化的GC监控,一种是命令行式的监控命令。

图形化GC监控

图形化GC监控主要介绍下JDK自带的Java VisualVM。

Java VisualVM存在JDK的安装目录下的bin文件夹中。主要用于:

  • 生成并分析堆的内存转储;
  • 在MBeans上观察并操作;
  • 监视垃圾回收;
  • 内存和CPU性能分析;

更多详细内容请看这里

GC监控命令

GC监控命令常用的有jstat和jmap

  • jstat

jstat用于监视虚拟机各种运行状态信息的命令行工具。可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
其命令格式为jstat [ option vmid|lvmid [interval[s|ms] [count]]],其中vmid是连接本地虚拟机用的,lvmid是连接远程虚拟机用的,参数interval和count代表查询间隔和次数,省略说明查询1次。假设需要每250毫秒查询一次进程1234垃圾收集的状态,一共查询3次,则命令为jstat -gc 1234 250 3,输出如下:

1
2
3
4
S0C S1C S0U S1U EC EU OC OU PC PU YGC YGCT FGC FGCT GCT
3008.0 3072.0 0.0 1511.1 343360.0 46383.0 699072.0 283690.2 75392.0 41064.3 2540 18.454 4 1.133 19.588
3008.0 3072.0 0.0 1511.1 343360.0 47530.9 699072.0 283690.2 75392.0 41064.3 2540 18.454 4 1.133 19.588
3008.0 3072.0 0.0 1511.1 343360.0 47793.0 699072.0 283690.2 75392.0 41064.3 2540 18.454 4 1.133 19.588

其中各列代表的意思如下:
S0C 输出Survivor0空间的大小。单位KB。
S1C 输出Survivor1空间的大小。单位KB。
S0U 输出Survivor0已用空间的大小。单位KB。
S1U 输出Survivor1已用空间的大小。单位KB。
EC 输出Eden空间的大小。单位KB。
EU 输出Eden已用空间的大小。单位KB。
OC 输出老年代空间的大小。单位KB。
OU 输出老年代已用空间的大小。单位KB。
PC 输出持久代空间的大小。单位KB。
PU 输出持久代已用空间的大小。单位KB。
YGC 新生代空间GC时间发生的次数。
YGCT 新生代GC处理花费的时间。
FGC full GC发生的次数。
FGCT full GC操作花费的时间
GCT GC操作花费的总时间。

  • jmap

jamp是内存映射工具,用于生成堆转储快照(一般称为heapdump或者dump文件)。jmap的作用不仅仅是为了生成dump文件,还可以查询finalize执行队列、java堆和永久代的详细信息,如空间使用率、当前用的是哪种垃圾收集器等。
其命令格式为jmap [ option ] vmid,常用option有-dump和-heap,-dump用于生成java堆转储快照。-heap用于显示java堆的详细信息,如使用哪种回收器、参数配置、分代情况等。

查看进程pid的内存使用情况
命令jmap -histo:live pid > mem
或者
命令jmap -histo pid > mem

总结

本篇主要科普了下JVM的架构和Java的内存分配。在JVM架构中主要关注运行时数据区域,这里分为两大类,线程共享的方法区和Java堆,线程私有的程序计数器、Java栈和本地方法栈,线程私有是指每个线程都会有这3个模块。在整个运行时数据区域只有程序计数器不会发生OOM,其它部分都会发生OOM,OOM的主要类型有Java heap space、PermGen space、OutOfMemoryError和StatckOverFlowError

附加:直接内存

直接内存并不是JVM管理的内存,则直接内存在分配时不会受到Java堆大小的限制,但是会受到本机内存大小的限制,所有也可能会抛OutOfMemoryError异常。可以这样理解,直接内存,就是JVM以外的机器内存,比如,你有4G的内存,JVM占用了1G,则其余的3G就是直接内存。

JDK中有一种基于通道(Channel)和缓冲区(Buffer)的内存分配方式,将由C语言实现的native函数库分配在直接内存中,用存储在JVM堆中的DirectByteBuffer来引用。
在NIO类中引入一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。

您的肯定,是我装逼的最大的动力!