赞
踩
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
目前常用的虚拟机是HotSpot虚拟机;
Java是一门抽象程度特别高的语言,提供了自动内存管理等一系列的特性。这些特性直接在操作系统上实现是不大可能的,所以就需要JVM进行一番转换;
JVM: JVM是Java程序能够运行的核心,但如果没有.class文件,JVM无法做任何事情,JVM相当于模拟了一个仅适用于Java的操作系统;
JRE: 仅仅是JVM,是无法完成一次编译,处处运行的。它需要一个基本的类库,比如怎么操作文件、怎么连接网络等。而Java体系很慷慨,会一次性将JVM运行所需的类库都传递给它。JVM标准加上实现的一大堆基础类库,就组成了Java的运行时环境,也就是我们通常说的JRE;
JDK: Java开发工具包,提供了javac、java、jar等。它是Java开发的核心;
JIT: JIT 是 just in time 的缩写, 也就是即时编译器,是Java运行时环境的一个组件,通过在运行时将字节码编译为本机机器代码来提高 Java应用程序的性能;
当我们使用 Java 命令运行 .class 文件的时候,实际上就相当于启动了一个 JVM 进程。然后 JVM 会翻译这些字节码,它有两种执行方式。常见的就是解释执行,将 opcode + 操作数翻译成机器代码;另外一种执行方式就是 JIT,也就是我们常说的即时编译,它会在一定条件下将字节码编译成机器码之后再执行。
解释执行:运行时,逐行解释源代码,并且每次执行相同的代码都需要重新解释,导致额外的开销;但是因为不需要存储编译后的代码,所以占用内存少;
JIT:当JIT编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用;但是他需要一定的启动时间来编译代码,这可能导致程序的启动速度较慢。
机器码的运行效率肯定是高于Java解释器的。所以在实际情况中,为了运行速度以及效率,我们通常采用两者相结合的方式进行Java代码的编译执行。
JVM分为五大模块:类装载器子系统、运行时数据区,执行引擎、本地方法接口和垃圾收集模块;
JVM内存在运行时数据区中,JVM内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分;
线程共有部分:方法区、堆;
线程私有部分:栈、本地方法栈、程序计数器
在jdk7以及jdk7之前,方法区被称为永久代(PermGen);
方法区Java8之后的变化:
Java8为什么要将永久代替换成元空间(Metaspace)?
什么是程序计数器
程序计数器:也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
PC寄存器的特点
a、区别于计算机硬件的PC寄存器,两者略有不同。计算机用pc寄存器来存放“伪指令”或地址,而相对于虚拟机,pc寄存器它表现为一块内存,虚拟机的pc寄存器的功能也是存放伪指令,更确切的说是存放将要执行指令的地址;
b、当虚拟机正在执行的方法是一个本地方法的时候,jvm的pc寄存器存储的值是undefined;
c、程序计数器是线程私有的,它的生命周期与线程相同,每个线程都有一个;
d、此内存区域是是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域;
Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。
因此,为了线程切换后恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
什么是虚拟机栈
Java虚拟机栈也是线程私有的,即生命周期和线程相同。Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直到执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
什么是栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法的返回地址等信息。每个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出战的过程;
-Xss 为jvm启动的每个线程分配的内存大小;
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。
操作数栈
操作数栈也称操作栈,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。
动态链接
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接。
动态链接作用:将符号引用转换成直接引用;
方法返回地址
方法返回地址存放调用该方法的PC寄存器的值;
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务;
特点:
a、本地方法栈加载native方法,native类方法存在的意义当然是填补Java代码不方便实现的缺陷而提出的;
b、虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的native方法服务;
c、是线程私有的,它的生命周期与线程相同,每个线程都有一个;
对于Java应用程序来说,Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例,Java世界里几乎所有的对象实例都在这里分配内存。几乎是指从实现角度来看,随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持,即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。
补充:
逃逸分析:是一种编译器优化技术,主要用于分析指针动态范围。在Java程序中,当一个变量(或对象)在方法中被分配后,其指针有可能被返回或全局引用,从而被其他方法或线程所引用,这种现象被称为指针或引用的逃逸;
1、Java虚拟机所管理的内存中最大的一块;
2、堆是jvm所有线程共享的;
堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer(TLAB)
3、在虚拟机启动的时候创建;
4、唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存;
5、Java堆是垃圾收集器管理的主要区域;
6、很多时候Java堆也称为GC堆,从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden空间、From Survivor空间、To Survivor空间;
7、Java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制);
8、方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候才移除;
9、如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常;
-Xmx10m:设置Java应用最大可用内存为10M;
-Xms2m:设置最小内存为2M;
Java7 Hotspot虚拟机中将堆内存分为3部分:
Java8以后
由于方法区的内存再分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了,在Java11正式发布以后,官方文档中没有提到“永久代”,而只有年轻代和老年代;
年轻代
年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成一个Eden Space和两个Suvivor Space(from和to)。
年老代
年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁;
配置新生代和老年代堆结构占比
默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3;
修改占比 - XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5;
Eden空间和另外两个Survivor空间占比分别为8:1:1;
可以通过操作选项 - XX:SurvivorRatio调整这个空间比例。比如:-XX:SurvivorRatio=8;
几乎所有的Java对象都在Eden区创建,但80%的对象生命周期都很短,创建出来就会被销毁;
JVM设计者不仅需要考虑到内存如何分配,在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,因此还需要考虑GC执行完内存回收后是否在空间中产生内存碎片
分配过程
1、new的对象先放在伊甸园区,该区域有大小限制;
2、当伊甸园区域填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中不再被其他对象引用的对象进行销毁,再加载新的对象放到伊甸园区;
3、然后将伊甸园区中的剩余对象移动到幸存者0区;
4、如果再次触发垃圾回收,此时上次幸存下来的放在幸存者0区的,如果没有回收,就会放到幸存者1区;
5、如果再次经历垃圾回收,此时后重新返回幸存者0区,接着再去幸存者1区;
6、如果累计次数达到默认的15次,就会进入老年区;可以通过设置参数,调整阈值 - XX:MaxTenuringThreshold=16;
7、老年区内存不足,就会触发Major GC进行老年区的内存清理;
8、如果老年区执行了Major GC之后仍然没有办法进行对象的保存,就会报OOM异常;
Java中的堆是GC收集垃圾的主要区域。GC分为两种:一种是部分收集器(Partial GC),另一类是整堆收集器(Full GC)。
部分收集器:不是完整收集java堆的收集器,它又分为:
年轻代GC(Minor GC)触发机制:
老年代GC(Major GC)触发机制:
Full GC触发机制:
在JDK1.7之前,HotSpot 虚拟机把方法区当成永久代来进行垃圾回收。但从JDK1.8开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。HotSpot取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在,只不过取代永久代的是元空间而已。
相比于之前的永久代划分,Oracle为什么要做这样的改进呢?
1、在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池等的大小和方法数量等,-XX:MaxPermSize指定太小很容易造成永久代内存溢出;
2、移除永久代是为融合HotSpot VM与JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代;
3、永久代会为GC带来不必要的复杂度,并且回收效率偏低;
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
元空间、永久代是方法区具体的落地实现。方法区看作是一块独立于Java堆的内存空间,它主要是用来存储所加载的类信息的
方法区的特点
方法区的内部结构
类加载器将class文件加载到内存之后,将类的信息存储到方法区中。
方法区中存储的内容:
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
域信息
域信息,即为类的属性,成员变量
JVM必须在方法区中保存类所有的成员变量相关信息及声明顺序;
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient的某个子集);
方法信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
常量池vs运行时常量池
字节码文件中,内部包含了常量池;
方法区中,内部包含了运行时常量池;
常量池:存放编译期间生成的各种字面量与符号引用;
运行时常量池:常量池表在运行时的表现形式;
编译后的字节码文件中包含了类型的信息、域信息、方法信息等。通过ClassLooader将字节码文件的常量池中的信息加载内存中,存储在方法区的运行时常量池中;
理解为字节码中的常量池Constant pool只是文件信息,它想要执行就必须加载到内存中。而Java程序是靠JVM,更具体的来说是JVM的执行引擎来解释执行的。执行引擎在运行时常量池中取数据,被加载的字节码常量池中的信息是放到了方法区的运行时常量池中。
它们不是一个概念,存放的位置是不同的。一个在字节码文件中,一个在方法区中。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant pool table),包括各种字面量和对类型、域和方法的符号引用。
常量池,可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
字面量:
描述:指由字母、数字等构成的字符串和数值常量,字面量只可以右值出现;
比如:int a = 1 这里a为左值,1为右值,1就是字面量。
符号引用:
描述:符号引用是编译原理中的概念,是相对于直接引用来说,主要包括了以下三大类;
运行时常量池
只有在运行时被加载到内存后,这些符号才有对应的内存地址,那么这些常量池一旦被装入内存就变成运行时常量池,对应的符号引用会转变为被加载到内存区域的代码的直接引用,也就是我们说的动态链接;
常量池表Constant pool table:
在方法区中对常量池表的符号引用:
堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生OutOfMemoryError的异常。
新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行Full GC,之后如果空间还不足以存放新对象则抛出OutOfMemoryError异常。
常见原因:
关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
1、如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
2、如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常;
创建线程导致内存溢出异常:
由于在windows平台的虚拟机中,Java的线程是映射到操作系统的内核线程上,无限制地创建线程会对操作系统带来很大压力,可能会由于创建线程数量过多而导致操作系统假死;
运行时常量池内存溢出
可以使用String::intern(),这是一个本地方法,它的作用是如果字符串常量池中已经包含了一个等于此String对象的字符串,则返回常量池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用;
字符串创建有两种方式:
1、String s1 = “hello”;
2、String s2 = new String(“hello”);
两者的区别:
第一种方式,首先JVM会去字符串常量池中寻找有没有“hello“这个字符串,如果有,则将常量池中存放”hello"的地址直接赋值给s1;如果没有,则在常量池中开辟一块空间存放“hello",然后将常量池中“hello"空间地址直接赋值给s1;
这种方法开辟的空间为0个或1个
第二种方式,首先JVM会去字符串常量池里寻找有没有“hello"字符串,如果有,就会在堆中重新开辟一个空间,用来存放常量池中“hello“的空间地址,然后再让s2指向这个堆中开辟的空间的地址;若没有,它会先在常量池中开辟一块空间用来存放“hello“,然后在堆中开辟一块空间,用来存放常量池中”hello“的空间地址。这种方法,无论常量池中有没有“hello“,都会在堆中开辟一块空间;
这种方法开辟的空间为1个或2个
方法区内存溢出
方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。测试:可以产生大量的类去填满方法区,直到溢出为止。比如使用CGLib的方式;
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。
1、类加载子系统负责从文件系统或是网络中加载.class文件,class文件在文件开头有特定的文件标识;
2、把加载后的class类信息存放于方法区,除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射);
3、ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定;
4、如果调用构造器实例化对象,则该对象存放在堆区;
1、class file存在于本地硬盘上,可以理解为设计师画在纸上的模版,而最终这个模版在执行的时候是要加载到JVM当中来,根据这个文件实例化出n个一模一样的实例;
2、class file加载到JVM中,被称为DNA元数据模版;
3、在.class文件–>JVM–>最终成为元数据模版,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员角色;
类使用的7个阶段
类从被加载到虚拟机内存中开始,到卸载出内存,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中验证、准备、解析 3个部分统称为连接;
图中,加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)。接下来讲解加载、验证、准备、解析、初始化五个步骤,这五个步骤组成了一个完成的类加载过程。使用没什么好说的,卸载属于GC的工作。
加载是类加载的第一个阶段。有两种时机会触发类加哎:
预加载
虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常用的,像java.lang.、java.util.、java.io.*等等,因此随着虚拟机一起加载。要证明这一点可以写一个空的main函数,设置虚拟机参数为“- XX:+TraceClassLoading”来获取类加载信息,运行一下:
运行时加载
虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类。
加载阶段做了三件事情:
虚拟机规范对这三点的要求并不具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如第一条,根本没有指明二进制字节流要从哪里来,怎么来,因此单单就这一条,就能变出许多花样来;
总而言之,在类加载整个过程中,这部分是对于开发者来说可控性最强的一个阶段;
连接包含三个步骤:分别是验证、准备、解析三个过程;
验证
连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全;
Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
验证阶段将会做以下几个工作:
准备
准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配。关于这一点,有两个地方注意一下:
各个数据类型的零值如下表:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | “\u0000” |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
面试题:下面有两段代码,code1将会输出0,而code2将会无法编译通过。
code1:
public class Code1 {
static int a;
public static void main(String[] args) {
System.out.println(a);
}
}
code2:
public class Code2 {
public static void main(String[] args) {
int a;
System.out.println(a);
}
}
注意:
这是因为局部变量不像类变量那样存在准备阶段。类变量有两次初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。
因此,即时程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值,但局部变量就不一样了,如果没有给它赋初始值,是不能使用的。
解析Resolution
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。来了解一下符号引用和直接引用有什么区别:
1、符号引用
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:
总而言之,符号引用是对于类、变量、方法的描述。符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。
2、直接引用
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位目标的句柄。直接引用和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机示例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在在内存中了。
解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。那这个阶段都做了哪些工作呢?大致可以分为:
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式布局参与外,其余动作都完全是由Java虚拟机来主导控制。知道初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
初始化阶段就是执行类构造器()方法的过程。类构造器方法并不是程序员在Java代码中直接编写的方法,它是javac编译器的自动生成物,类构造器方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如:
public class StaticTest {
static {
a = 2; //给变量赋值可以正常编译通过
System.out.println(a); //这句编译器会提示"非法向前引用"
}
static int a = 1;
}
先父后子,先静态后普通
<cinit>方法和<init>方法有什么区别?
主要是为了弄明白类的初始化和对象的初始化之间的差别。
static字段和static代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的static代码块只会执行一次,它对应的是方法。
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
注意:JVM主要在程序第一次主动使用类的时候,才会去加载该类,也就是说,JVM并不是在一开始i就把一个程序中所有的类都加载到内存中,而是在用的时候才把它加载进来,而且只加载一次。
1、jvm支持两种类型的加载器,分别是引导类加载器和自定义加载器;
2、引导类加载器是由c/c++实现的,自定义加载器是由Java实现的;
3、jvm规范定义自定义加载器是指派生于抽象类ClassLoader的类加载器;
4、按照这样的加载器的类型划分,在程序中我们最常见的类加载器是:引导类加载器BootStrapClassLoader、自定义类加载器(Extension Class Loader、System Class Loader、User_defined ClassLoader)
启动类加载器
1、这个类加载器使用c/c++实现,嵌套在jvm内部;
2、它用来加载Java的核心类库(JAVA_HOME/jre/lib/rt.jar、resource.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类;
3、并不继承自java.lang.ClassLoader,没有父加载器;
扩展类加载器
1、java语言编写,由sum.misc.Launcher$ExtClassLoader实现;
2、从java.ext.dirs系统属性所指定的目录下加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载;派生于ClassLoader;
3、父类加载器为启动类加载器;
系统类加载器
1、java语言编写,由sun.misc.Lanucher$AppClassLoader实现;
2、该类加载是程序中默认的加载器,一般来说,Java应用的类都是由它来完成加载的,它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库,派生于ClassLoader;
3、父类加载器为扩展类加载器;
4、通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器;
用户自定义类加载器
在日常的Java开发中,类加载几乎是由三种加载器配合执行的,在必要时我们还可以自定义类加载器,来定制类的加载方式。
双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。
为什么需要双亲委派模型呢?假设没有双亲委派模型,试想一个场景:
黑客自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍修改。比如equals函数,这个函数经常使用,如果在这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到JVM中。此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致“病毒代码”被执行。
而有了双亲委派模型,黑客自定义的java.lang.String类永远不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。
或许你会想,我在自定义的类加载器里面强制加载自定义的java.lang.String类,不去通过调用父类加载器不就好了吗》确实,这样是可行。但是,在JVM中,判断一个对象是否是某个类型时,如果该对象的实际类型与代比较的类型加载器不同,那么会返回false。
举个简单的例子:
ClassLoader1、ClassLoader2都加载java.lang.String类,对应Class1、Class2对象。那么Class1对象不属于ClassLoader2对象加载的java.lang.String类型。
双亲委派模型的原理很简单,实现也简单。每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加载。其实ClassLoader类默认的loadClass方法已经帮我们写好了,我们无需去写。
垃圾回收概述
说起垃圾收集(Garbage Collection,简称GC),有不少人把这项技术当作Java语言的伴生产物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始用内存动态分配和垃圾收集技术的语言。垃圾收集需要完成的三件事:
哪些内存需要回收?
什么时候回收?
如何回收?
java垃圾回收的优缺点:
优点:
1、不需要考虑内存管理;
2、可以有效的防止内存泄漏,有效的利用可使用的内存;
3、由于垃圾回收机制,Java中的对象不再有“作用域”的概念,只有对象的引用才有“作用域”;
缺点:
Java开发人员不了解自动内存管理,内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力。
引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用的问题。
优点:
实现简单,执行效率高,很好的和程序交织。
缺点:
无法检测出循环引用
譬如有A和B两个对象,他们都互相引用,除此之外都没有任何对外的引用,那么理论上A和B都可以被作为垃圾回收掉,但实际如果采用引用计数算法,则A、B的引用计数都是1,并不满足被回收的条件,如果A和B之间的引用一直存在,那么就永远无法被回收了
但是在Java程序这两个对象仍然会被回收,因为java中并没有使用引用计数算法。
在主流的商用程序语言如:java、C#等的主流实现中,都是通过可达性分析来判断对象是否存活的。此算法的基本思路就是通过一些列的“GC Roots“的对象作为起始点,从起始点开始向下搜集到对象的路径。搜索所经过的路径称为引用链,当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
finalize()方法最终判定对象是否存活:
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓行”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
第一次标记:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
没有必要:
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
有必要:
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己–只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
注意:
Finalizer线程去执行它们的finalize()方法,这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finaliize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
思想也很简单,就是根据对象的生命周期将内存划分,然后进行分区管理。当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
1、弱分代假说:绝大多数对象都是朝生夕灭的;
2、强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡;
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存锤。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域—因而才有了“Minor GC"、“Major GC“、”Full GC“这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法–因而发展出了“标记-复制算法”、“标记-清楚算法”、“标记-整理算法”等针对性的垃圾收集算法。
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
什么是标记-清除算法?
最早出现也是最基础的垃圾收集算法是“标记-清除”算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
标记过程就是对象是否属于垃圾的判定过程,这在前一节讲述垃圾对象标记判定算法时其实已经介绍过了。之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。
标记-清除算法有两个不足之处:
第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
什么是标记-复制算法
标记-复制算法常被简称为复制算法。
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,在1969年Fenichel提出了一种称为“半区复制”的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可;
但是这种算法也有缺点:
注意事项:
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭:的特点做了更量化的诠释–新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1:1的比例来划分新生代的内存空间。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,即每次新生代中可用内存空间为新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”算法,其中标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:
是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
1、垃圾回收器与垃圾回收算法
垃圾回收算法分为两类:第一类算法判断对象生死算法,如引用计数法、可达性分析算法;第二类收集死亡对象方法有四种,如标记-清除算法、标记-复制算法、标记整理算法。一般的实现采用分代回收算法,根据不同代的特点应用不同的算法。垃圾回收算法是内存回收的方法论。垃圾收集器是算法的落地实现。和回收算法一样,目前还没有出现完美的收集器,而是要根据具体的应用场景选择最合适的收集器,进行分代收集。
2、垃圾收集器分类
串行垃圾回收
串行垃圾回收是为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境。
并行垃圾回收
多个垃圾收集器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景。
并发垃圾回收(CMS)
用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程,不需要停顿用户线程,互联网应用程序中经常使用,适用对响应时间有要求的场景。
G1垃圾回收
G1垃圾回收器将堆内存分割成不同的区域然后并发地对其进行垃圾回收。
3、七种垃圾收集器及其组合关系
根据分代思想,我们有七种主流的垃圾回收器
新生代垃圾收集器:Serial、ParNew、Parallel Scavenge
老年代垃圾收集器:Serial Old、Parallel Old、CMS
整理收集器:G1
垃圾收集器的组合关系
JDK8中默认使用组合是:Parallel Scavenge GC、ParallelOld GC;
JDK9默认是用G1为垃圾收集器;
JDK14弃用了:Parallel Scavenge GC、Parallel OldGC
JDK14移除了CMS GC
4、GC性能指标
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%;
暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间;
内存占用:Java堆所占内存的大小;
收集频率:垃圾收集的频次;
单线程收集器,“单线程”的意义不仅仅说明它只会使用一个CPU或一个收集线程去完成垃圾收集工作;
更重要的是它在垃圾收集的时候,必须暂停其他工作线程,直到垃圾收集完毕;
“Stop The World“这个词也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。
Serial收集器也并不是只有缺点;Serial收集器由于简单并且高效,对于单CPU环境来说,由于Serial收集器没有线程间的交互,专心做垃圾收集自然可以做获得最高的垃圾收集效率;
使用方式:-XX:+UseSerialGC
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
ParNew收集器的工作过程:
ParNew收集器在单CPU服务器上的垃圾收集效率绝对不会比Serial收集器高;
但是在多CPU服务器上,效果会明显比Serial好;
使用方式:- XX:UseParNewGC
设置线程数:XX:ParllGCThreads
1、什么是Parallel Scavenge
又称为吞吐量优先收集器,和ParNew收集器类似,是一个新生代收集器。使用复制算法的并行多线程收集器。Paralle Scavenge是Java1.8默认的收集器,特点是并行的多线程回收,以吞吐量优先。
2、特点
3、使用场景
适合后台计算,交互不多的任务,如批量处理,订单处理,科学计算等。
4、参数
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
特点:
执行流程:
应用场景:主要用于Client模式
1、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
2、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用;
参数设置:
使用方式:-XX:+UseSerialGC
注意事项:
需要说明一下,Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集球,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是最直接以Seriial Old代替PS MarkSweep进行讲解。
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。
1、Parallel Old收集器的工作过程:
2、应用场景
JDK1.6及之后用来代替老年代的Serial Old收集器;
特别是在Server模式,多CPU的情况下;
这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的“给力”应用组合;
3、设置参数
“- XX:UseParallelOldGC“:指定使用Parallel Old收集器;
CMS是以获取最短垃圾收集停顿时间为目标的收集器,CMS收集器的关注点尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合与用户交互的程序,目前很大一部分的Java应用几种在互联网的B/S系统服务器上,这类应用尤其注重服务器的响应速度,系统停顿时间最短,给用户带来良好的体验,CMS收集器使用的算法是标记-清除算法实现的;
整个过程分为4个步骤:
1、初始标记
2、并发标记
3、重新标记
4、并发清除
其中初始标记和重新标记都需要StopTheWorld
CMS整个过程比之前的收集器要复杂,整个过程分为4个阶段即初始标记、并发标记、重新标记、并发清除;
由于最消耗时间的并发标记与并发清除阶段都不需要暂停工作,因为整个回收阶段是低停顿(低延迟)的;
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都是基于一个能保障一致性的快照中才能够进行分析。
垃圾回收器的工作流程大体如下:
1、标记出哪些对象存活,哪些是垃圾(可回收);
2、进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用;
三色标记
三色标记作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成一下三种颜色:
要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象;
我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
1、初始时,所有对象都在【白色集合】中;
2、将GC Roots直接引用到的对象挪到【灰色集合】中;
3、从灰色集合中获取对象:
注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。
当Stop The World(以下简称STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
Garbage First是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
1、G1把内存划分为多个独立的区域Region;
2、G1仍然保留分代思想,保留了新生代和老年代,但他们不再是物理隔离,而是一部分Region的集合;
3、G1能够充分利用多CPU、多核环境硬件优势,尽量缩短STW;
4、G1整体采用标记-整理算法,局部采用是复制算法,不会产生内存碎片;
5、G1的停顿可预测,能够明确指定一个时间段内,消耗在垃圾收集上的时间不超过设置时间;
6、G1根据各个Region里面垃圾的价值大小,会维护一个优先列表,每次根据允许的时间来回收价值最大的区域,,从而保证在有限时间内高效的收集垃圾;
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间、或者老年代空间。
将整个堆空间细分为若干个小的区域;
1、使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,为2的N次幂,即1MB,2MB,4MB,8MB,16MB,32MB;
2、虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。通过Region的动态分配方式实现逻辑上的连续;
3、G1垃圾收集器还增加了一个种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region就放到H。一般被视为老年代;
G1提供了两种GC模式,Young GC和Mixed GC,两种均是完全Stop The World的。
在G1 GC垃圾回收的过程一共有四个阶段:
初始标记:和CMS一样只标记GC Roots直接关联的对象;
并发标记:进行GC Roots Traceing(追踪)过程;
最终标记:修改并发标记期间,因程序运行导致发生变化的那一部分对象;
筛选回收:根据时间来进行价值最大化收集;
下面是G1收集的示意图:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。