jvm运行机制
java程序的具体运行过程如下:
(1)java源文件被编译器编译成字节码文件。
(2)JVM将字节码文件编译成相应操作系统的机器码。
(3)机器码调用相应操作系统的本地方法库执行相应的方法。
Java虚拟机:
类加载子系统(Class Loader SubSystem)
运行时数据区(Runtime Data Area)
执行引擎和本地接口库(Native Method Library)与操作系统交互。如图
如图1.1
其中:
- 类加载器子系统用于将编译好的.Class文件加载到JVM中
- 运行时数据区用于存储在jvm运行过程中产生的数据,包括程序计数器、方法区、本地方法区、虚拟机栈和虚拟机堆;
- 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将java字节码编译成具体的机器码,垃圾回收器用于回收在运行过程中不再使用的对象;
- 本地接口库用于调用操作系统的本地的方法库完成具体的指令操作。
多线程
jvm会调用操作系统的接口创建一个与之对应的原生线程;在jvm线程运行结束时,原生线程随之被回收。
JVM后台运行的线程主要有以下几个:
- 虚拟机线程(JVMthread):虚拟机线程在JVM到达安全点(SafePoint)时出现。
- 周期性任务线程: 通过定时器调度线程来实现周期性操作的执行。
- GC线程:GC线程支持JVM中不同的垃圾回收活动。
- 编译器线程:编译器线程在运行时将字节码动态编译成本地平台机器码,是jvm跨平台的具体体现。
- 信号分发线程: 接收发送到JVM的信号并调用JVM方法。
JVM的内存区域
分为线程私有区域(程序计数器、虚拟机栈、本地方法区)、线程共享区域(堆、方法区)和直接内存,如图1-2所示。
线程私有区域的生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁。在JVM内,每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存在与否和本地线程的启动和销毁对应。
线程共享区域随虚拟机的启动而创建,随虚拟机的关闭而销毁。
直接内存也叫作堆外内存,它并不是JVM运行时数据区的一部分,但在并发编程中被频繁使用。JDK的NIO模块提供的基于Channel(通道)与Buffer的I/O操作方式就是基于堆外内存实现的,NIO模块通过调用Native函数库直接在操作系统上分配堆外内存。然后使用DirectByteBuffer对象作为这块内存的引用对内存进行操作。java进程可以通过堆外技术避免在java堆和Native堆中来回复制数据带来的资源占用和性能消耗。因此堆外内存在高并发应用场景下被广泛使用(Netty、Flink、HBase、Hadoop都有用到堆外内存)。
程序计数器:线程私有,无内存溢出问题
虚拟机栈:线程私有,描述java方法的执行过程。
虚拟机栈是描述java方法的执行过程的内存模型,它在当前栈帧(Stack Frame)中存储了局部变量表、动态链接、方法出口等信息。同时,栈帧用来存储部分运行时数据及其数据结构,处理动态链接(Dynamic Linking)方法的返回值和异常分派(Dispatch Exception)。
栈帧用来记录方法的执行过程,在方法被执行时虚拟机会为其创建一个与之对应的栈帧,方法的执行和返回对应栈帧在虚拟机栈中的入栈和出栈。无论方法是正常运行完成还是异常完成(抛出了在方法内未被捕获的异常),都视为方法运行结束。图1-3展示了线程运行及栈帧变化的过程。线程1在CPU1上运行,线程2在CPU2上运行,在CPU资源不够时其他线程将处于等待状态(如图1-3中等待的线程N),等待获取CPU时间片。而在线程内部,每个方法的执行和返回都对应一个栈帧的入栈和出栈,每个运行中的线程当前只有一个栈帧处于活动状态。
本地方法区:线程私有
本地方法区和虚拟机栈的作用类似,区别是虚拟机栈为执行java方法服务,本地方法栈为Native方法服务。
堆:也叫作运行时数据区,线程共享
在JVM运行过程中创建的对象和产生的数据都被存储在堆中,堆是被线程共享的内存区域,也是垃圾收集器进行垃圾回收的最主要的内存区域。由于现代JVM采用分代收集算法,因此JAVA堆从GC(GarbageCollection,
垃圾回收)的角度还可以细分为:新生代、老年代和永久代。
方法区:线程共享
方法区也被称为永久代,用于存储常量、静态变量、类信息、即时编译器编译后的机器码、运行时常量池等数据,如图1-4所示。
JVM把GC分代收集扩展至方法区,即使用java堆的永久代来实现方法区,这样JVM的垃圾收集器就可以像管理java堆一样管理这部分内存。永久带的内存回收主要针对常量池的回收和类的卸载,因此可回收的对象很少。
常量被存储在运行时常量池(Runtime constant Pool)中,是方法区的一部分。静态变量也属于方法区的一部分。在类信息(Class文件)中不但保存了类的版本、字段、方法、接口等描述信息,还保存了常量信息。
在即时编译后,代码的内容将在执行阶段(类加载完成后)被保存在方法区被保存在方法区的运行时常量池中。java虚拟机对Class文件每一部分的格式都有明确规定。
JVM的运行时内存
JVM的运行时内存也叫作JVM堆,从GC的角度可以将JVM堆分为新生代、老年代和永久代。其中新生代默认占1/3堆空间,老年代默认占2/3堆空间,永久代占非常少的堆空间。新生代又分为Eden区、ServivoFrom区和ServivorTo区,如图1-5
(1) Eden区: java新创建的对象首先会被存放到Eden区。如果新创建的对象属于大对象,则直接将其分配到老年代。(大对象的定义和具体的JVM版本、堆大小和垃圾回收策略有关,一般为2kb-128kb)可通过XX:pretenureSizeThreshold设置其大小。在Eden区的内存空间不足时会触发MinorGc,对新生代进行一次垃圾回收。
(2)ServivorTo区: 保留上一次MinorGC时的幸存者。
(3) ServivorFrom区: 将上一次MinorGc时的幸存者作为这一次MinorGC的被扫描者。
新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下。
(1)把在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区。如果某对象的年龄达到老年代的标准(对象晋升老年代的标准由XX:MaxTenuringThreshold设置,默认为15),则将其复制到老年代,同时把这些对象的年龄加1;如果ServivorTo区的内存不够,则直接将其复制到老年代;如果对象属于大对象,也可以复制到老年代。
(2)清空Eden区和ServivorForm区中的对象。
(3)将ServivorTO区和ServivorForm区互换,原来的ServivorTo区成为下一次GC时的ServivorForm区。
老年代
主要存放有长生命周期的对象和大对象。老年代的GC过程叫作MajorGC。在老年代,对象比较稳定,MajorGC不会被频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,在MinorGC过后仍然出现老年代空间不足或无法找到更大的连续空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
MajorGC采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未标记的对象,并释放内存空间。
因为要先扫描老年代的所有对象再回收,所以MajorGC的耗时较长。MajorGC的标记清除算法容易产生内存碎片。在老年代没有内存空间可分配时,会抛出out of Memory 异常。
永久代
永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。永久代和老年代、新生代不用,GC不会在程序运行期间对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会抛出Out of Memory 异常,比如Tomcat引用Jar文件过多导致JVM内存不足而无法启动。
需要注意的是,在java8 中永久代已经被元数据区(也叫作元空间)取代。元数据区的作用和永久代类似,二者最大的区别在于: 元数据区并没有使用虚拟机内存,而是直接使用操作系统
的本地内存。因此,元空间的大小不受JVM内存的限制,只和操作系统的内存有关。
在java8中,JVM将类的元数据放入本地内存(Native Memory)中,将常量池和类的静态变量放入堆中,这样JVM能够加载多少元数据信息就不再由JVM的最大可用内存(MaxPermSize)空间决定,而由操作系统的实际可用空间决定。
垃圾回收算法
如何确定垃圾
java采用引用计数法和可达性分析来确定对象是否应该被回收,其中,引用计数法容易产生循环引用的问题,可达性分析通过根搜索算法(Gc Roots tracing)来实现。根搜索算法以一系列GC Roots的点作为起点向下搜索,在一个对象到任何GC Roots都没有引用链条相连时,说明其已经死亡。根据搜索算法主要针对栈中的引用、方法区中的静态引用和JNI的引用展开分析,如图1-6所示。
引用计数法
在java中如果要操作对象,就必须**先获取该对象的引用,**因此可以通过引用计数法来判断一个对象是否可以被回收。在为对象添加一个引用时,引用计数加1;在为对象删除一个引用时,引用计数减1;如果一个对象的引用计数为0,则表示此刻该对象没有被引用,可以被回收。
引用计数法容易产生循环引用问题。循环引用指两个对象相互引用,导致它们的引用一直存在,而不能被回收,如图1-7所示,object1和object2互为引用,如果采用引用计数法,则Object1和obejct2互为引用,如果采用引用计数法,则Object1和Object2由于互为引用,其引用计数一直为1,因而无法被回收。
可达性分析
为了解决引用计数法的循环引用问题,java还采用了可达性分析来判断对象是否可以被回收。具体做法是首先定义一些GC Roots 对象,然后以这些GC Roots对象作为起点向下搜索,如果在GC roots和一个对象之间没有可达路径,则称该对象是不可达的。不可达对象要经过至少两次标记,则将被垃圾收集器回收。
java中常用的垃圾回收算法
标记清除(Mark-Sweep)、复制(Copying),标记整理( Mark-Compact)和分代收集(Generational Collecting)这四种垃圾回收算法
1.标记清除算法
标记清楚算法是基础的垃圾回收算法,其过程分为标记和清除两个阶段。在标记阶段标记所有需要回收的对象,在清除阶段清除可回收的对象并释放其所占用的内存空间,如图1-9所示。
由于标记清除算法在清理对象所占用的内存空间后并没有重新整理可用的内存空间,因此如果内存中可被回收的小对象居多,则会引起内存碎片化的问题,继而引起大对象无法获得连续可用空间的问题。
2.复制算法
复制算法是为了解决标记清除算法内存碎片化的问题而设计的。复制算法首先将内存分为大小相等的内存区域,即区域1和区域2,新生成的对象都被存放在区域1中,在区域1内存的对象存储满后会对区域1进行一次标记,并将标记后仍然存活的对象全部复制到区域2中,这时区域1将不存在任何存活的对象,直接清理整个区域1的内存即可,如图1-10所示
弊端: 复制算法的内存清理效率高且易于实现,但由于同一时刻只有一个内存区域可用,即可用的内存空间被压缩到原来的一半,因此存在大量的内存浪费。同时,在系统中有大量长时间存活的对象时,这些对象会在区域1和区域2来回复制而影响系统的运行效率。因此,该算法只在对象为(朝生夕死)状态时运行效率较高。
3.标记整理算法
标记整理算法结合了标记清除算法和复制算法的优点,其标记阶段和标记清除算法的标记阶段相同
在标记完成后将存活的对象移到内存的另一端,然后清除该端的对象并释放内存,如图1-11所示。
4.分代收集算法
无论是标记清除算法、复制算法还是标记整理算法,都无法对所有类型(长生命周期、短生命周期、大对象、小对象)的对象都进行垃圾回收。因此,针对不同的对象类型,JVM采用了不同的垃圾回收算法,该算法被称为分代收集算法。
分代收集算法根据对象的不同类型将内存划分为不同的区域,JVM将堆划分为新生代和老年代。新生代主要存放新生成的对象,其特点是对象数量多但是生命周期短,在每次进行垃圾回收时都有大量的对象被回收;老年代主要存放大对象和生命周期长的对象,因此可回收对象少,JVM根据不同的区域对象的特点选择了不同的算法。
目前,大部分JVM在新生代都采用了复制算法,因为在新生代中每次进行垃圾回收时都有大量的对象被回收,需要复制的对象(存活的对象)较少。在新生代的Eden区和ServivorFrom区的内存空间不足时会触发一次GC,该过程被称为MinorGC。在MinorGC后,在Eden区和ServivorFrom区中存活的对象会被复制到ServivorTo区,然后Eden区和ServivorFrom区被清理。如果此时在ServivorTo区无法找到连续的内存空间存储某个对象,则将这个对象直接存储到老年代。若Servivor区的对象经过一次GC后仍然存活,则其年龄加 1。在默认情况下,对象在年龄达到15时,将被移到老年代。