JVM深度解析

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java语言的一个非常重要的特点就是与平台的无关性。而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行。这就是Java的能够“一次编译,到处运行”的原因。

1.JVM的生命周期:

*启动。启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。

*运行。main()作为该程序初始线程的起点,任何其他线程均由该线程启动。

*消亡。当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。

当在电脑上运行一个程序时,就会运行一个java虚拟机,java虚拟机总是开始于main方法,main方法是程序的起点。

java的线程一般分为两种:守护线程和普通线程。守护线程是java虚拟机自己使用的线程,比如GC线程就是一个守护线程,当然你可以把自己的线程设置为守护线程,注意:main方法启动的初始线程不是守护线程。

只要java虚拟机中还有普通线程在执行,java虚拟机就不会停止,如果有足够的权限,你可以调用exit()方法终止线程。

 

2.JVM的体系结构:

1) 类装载器(ClassLoader)(用来装载.class文件)

2) 执行引擎(执行字节码,或者执行本地方法)

3) 运行时数据区(方法区、堆、虚拟机栈、程序计数器、本地方法栈)

 

首先我们对运行时数据区中的5个区域进行分析:

3. 运行时数据区:

3.1 堆:

所有线程共享的内存区域,在虚拟机启动时创建。

用来存储对象实例,如:String a = new String()中new String()创建了一个对象,该对象存放在堆内存中,而a 是存放在栈中的,堆中new String() 存放了栈中 a 的内存地址。

可以通过-Xmx和-Xms控制堆的大小

当在堆中没有内存完成实例分配,且堆也无法再扩展时,会报OutOfMemoryError异常。

java堆是垃圾回收器的主要工作区域。java对还可以分为新生代、老年代。但是垃圾回收器的永久代是在方法区中的,不在堆中。

(新生代:新建的对象由新生代分配内存;老年代:存放经过多次垃圾回收器回收仍然存活的对象;永久代:存放静态文件,如java类、方法等,永久代存放在方法区,对垃圾回收没有显著的影响)

3.1.1 新生代:

分为三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。(动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。)经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

GC可分为三种:Minor GC Major GC 和 Full GC

Minor GC :是清理新生代。触发条件:当Eden区满时,触发Minor GC。

Major GC:是清理老年代。是 Major GC 还是 Full GC,大家应该关注当前的 GC 是否停止了所有应用程序的线程,还是能够并发的处理而不用停掉应用程序的线程

Full GC :是清理整个堆空间—包括年轻代和老年代。触发条件:调用System.gc时,系统建议执行Full GC,但是不必然执行;老年代空间不足;方法区空间不足;通过Minor GC后进入老年代的平均大小大于老年代的可用内存;由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

3.1.2 老年代:

主要存放应用程序中生命周期长的内存对象。

老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。

MajorGC采用标记—清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。

当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

 3.1.3 永久代(永久代是在方法区中的,而不在堆中,这里只是为了总结GC的运行机制并和新生代、老年代进行比较才将永久代放在这里写):

指内存的永久保存区域,主要存放Class和Meta(元数据)的信息,Class在被加载的时候被放入永久区域. 它和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的MetaSpace区域所取代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入java堆中. 这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制.采用元空间取代永久代的原因:(1)为了解决永久代的OOM问题,元数据和class对象存在永久代中,容易出现性能问题和内存溢出。(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出(因为堆空间有限,此消彼长)。(3)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低(4)Oracle 可能会将HotSpot 与 JRockit 合二为一。

3.2 方法区:

所有线程共享

用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

当方法区无法满足内存的分配需求时,报OutOfMemoryError异常

方法区中有一个运行时常量池,用于存储编译期生成的各种字面量与符号引用,当常量池无法再申请到内存时报OutOfMemoryError异常。

3.3 虚拟机栈:

线程私有,声明周期与线程同步。

存储一些方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。

每个虚拟机线程都有一个私有的栈,一个线程的java栈在线程创建的时候被创建。

每个方法执行的同时都会创建一个栈帧,每个方法被调用直至完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。

当线程请求的栈深度大于虚拟机允许的深度时报StackOverFlowError异常。

当栈的扩展无法申请到足够的内存时报OutOfMemoryError异常。

3.4 本地方法栈:

主要是为虚拟机使用到的Native方法服务,Native 方法就是一个java调用非java代码的接口,该方法的实现由非java语言实现。Native方法用native修饰,没有方法体,因为方法体中的实现是非java语言的。

有时java需要调用操作系统的一些方法,而操作系统基本都是C语言写的,这时就需要使用到Native方法了。

Native方法关键字修饰的方法是一个原生态的方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。java语言本身不能对操作系统底层进行访问和操作,但是可以通过JNI(Java Native Interface)接口调用其他语言来实现对底层的访问。

3.5 程序计数器:

当前线程所执行的字节码的行号指示器,当前线程私有,由于他只是存储行号,一般就是一个数字,所以不会出现OutOfMemoryError异常。

其特点是:如果正在执行java方法,则这个计数器记录的是正在执行的虚拟机字节码指令地址,如果正在执行Native方法,则这个计数器为空(undefined),此内存区域是唯一一个在java虚拟机中没有规定任何OutOfMemoryError异常情况的区域。

使用场景:A线程先获取CPU时间片执行,当执行到一半的时候,B线程过来了,且优先级比A线程的高,所以处理器又去执行B线程了,把A线程挂起,当B线程执行完了以后,再回过头来执行A线程,这时就需要知道A线程已经执行的位置,也就是查看A中的程序计数器中的指令。

 

总结:java对象存放在堆中,常量存放在方法区的常量池中,虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据放在方法区,以上区域都是线程共享的。栈是线程私有的,存放该方法的局部变量(基本类型、对象引用)操作数栈、动态链接、方法出口等信息。一个java程序对应一个JVM,一个方法对应一个java栈。

4.1 垃圾收集器的种类:

4.1.1  Serial 收集器:

这个收集器是一个单线程的收集器,但它的单线程的意义不仅仅说明它会只使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。串行垃圾回收器是为单线程环境而设计的,如果你的程序不需要多线程,启动串行垃圾回收。串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。新生代、老年代使用串行回收;新生代复制算法、老年代标记-压缩;垃圾收集的过程中会Stop The World(服务暂停)

4.1.2 ParNew收集器:

ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩

4.1.3 Parallel 收集器:

Parallel 收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩

4.1.4 Parallel Old 收集器:

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供

4.1.5 CMS收集器:

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。

运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:
初始标记(CMS initial mark)
并发标记(CMS concurrent mark)
重新标记(CMS remark)
并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

  优点:并发收集、低停顿
  缺点:产生大量空间碎片、并发阶段会降低吞吐量

4.1.6 G1收集器:

空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。

可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。
G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。

5.1 java源码编译机制:

java源码是不能被机器识别的,需要经过编译器编译成JVM可以执行的.class字节码文件,再由解释器编译运行,即:Java源文件(.java) -- Java编译器 --> Java字节码文件 (.class) -- Java解释器 --> 执行。流程图如下:

java中字符只以Unicode存在,字符转换发生在JVM和OS交界处

5.2 类加载机制(ClassLoader):

java程序并不是一个可执行文件,是由多个独立的类文件组成。这些类文件并不是一次性全部装入内存,而是依据程序逐步载入。

JVM的类加载是通过ClassLoader及其子类来完成的,累的层次关系和加载顺序可以由下图来描述:

1)Bootstrap ClassLoader

 是JVM的根ClassLoader,由C++实现;加载Java的核心API:$JAVA_HOME中jre/lib/rt.jar中所有class文件的加载,这个jar中包含了java规范定义的所有接口以及实现;JVM启动的时候就开始初始化此ClassLoader。

2)Extension ClassLoader

加载java扩展API(lib/ext中的类)

3)App ClassLoader

加载Classpath目录下定义的class

 4)Custom ClassLoader

属于应用程序根据自身需要自定义的ClassLoader,如tomcat、Jboss都是会根据J2EE规范自行实现ClassLoader

 

注意:加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。

 

双亲委派机制

JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归。如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

作用:1)避免重复加载;2)更安全。如果不是双亲委派,那么用户在自己的classpath编写了一个java.lang.Object的类,那就无法保证Object的唯一性。所以使用双亲委派,即使自己编写了,但是永远都不会被加载运行。

破坏双亲委派机制

双亲委派机制并不是一种强制性的约束模型,而是Java设计者推荐给开发者的类加载器实现方式。

线程上下文类加载器,这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那么这个类加载器就是应用程序类加载器。像JDBC就是采用了这种方式。这种行为就是逆向使用了加载器,违背了双亲委派模型的一般性原则。

 

 

 参考文档:

https://www.cnblogs.com/IUbanana/p/7067362.html

https://blog.csdn.net/leaf_0303/article/details/78953669

 

posted @ 2018-11-29 13:58  王者之巅  阅读(7515)  评论(1编辑  收藏  举报