JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
JVM 只能认识字节码,也就是他只认识xxx.class 这种类型的文件,并将它们解释到系统的 API 调用。针对不同的系统有不同的 jvm 实现,有 Linux 版本的 jvm 实现,也有 Windows 版本的 jvm 实现,但是同一段代码在编译后的字节码是一样的。引用上面的例子,在 Java API 层面,我们调用系统声音设备的代码是唯一的,和系统无关,编译生成的字节码也是唯一的。但是同一段字节码,在不同的 jvm 实现上会映射到不同系统的 API 调用,从而实现代码的不加修改即可跨平台运行。
JRE(Java Runtime Environment),即Java运行环境
JRE包括Java虚拟机和Java程序所需的核心类库。如果要想运行一个开发好的Java程序,只需要在计算机中安装JRE即可。
总结:JRE=JVM+Java类库
JDK(Java Development Kit),即Java开发工具包
JDK是提供给Java开发人员使用的,包含了JRE和一些Java开发工具,如编译工具(javac.exe)、打包工具(jar.exe)等。所以安装了JDK就不需要再安装JRE了。
三者之间的关系如下:
区别和联系:
jdk是jre的超集,是在jre的基础上增加了编译器及其他一些开发工具。
jre就是java运行时环境,包括了jvm和其它一些java核心api,任何一台电脑,只有安装了jre才可以运行java程序.
如果只是要运行JAVA程序,之需要JRE就可以。 JRE通常非常小,也包含了JVM.
如果要开发JAVA程序,就需要安装JDK。
JVM是运行在操作系统之上的,它与硬件没有直接的交互 ,同时你的程序是运行在JVM里面的。
jvm虚拟机位于操作系统的堆中
新生代(伊甸园区)
养老区
永久区
这个区域是常驻内存的,这个是存放JDK自身携带的Class对象,Interface元数据,存储的是JAVA运行的一些环境的。
元空间
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机使用的java类型,这个叫做类加载机制。
Java中.类的加载,连接,初始化过程都是在程序运行期间完成的.
Java语言系统自带有三个类加载器
public class Car {
public static void main(String[] args) {
Car car = new Car();
// 通过对象来获取模板
Class<? extends Car> aClass = car.getClass();
ClassLoader classLoader = aClass.getClassLoader();
// sun.misc.Launcher$AppClassLoader@18b4aac2 应用类加载器
System.out.println(classLoader);
// sun.misc.Launcher$ExtClassLoader@4554617c 扩展类加载器
System.out.println(classLoader.getParent());
// null 为什么为空呢?
// 原因是Bootstrap Loader(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null
System.out.println(classLoader.getParent().getParent());
}
}
如果一个类加载器收到了类加载器的请求.它首先不会自己去尝试加载这个类.而是把这个请求委派给父加载器去完成.每个层次的类加载器都是如此.因此所有的加载请求最终都会传送到Bootstrap类加载器(启动类加载器)中.只有父类加载反馈自己无法加载这个请求(它的搜索范围中没有找到所需的类)时.子加载器才会尝试自己去加载。
双亲委派模型的优点:java类随着它的加载器一起具备了一种带有优先级的层次关系.
例如类java.lang.Object,它存放在rt.jart之中.无论哪一个类加载器都要加载这个类.最终都是双亲委派模型最顶端的Bootstrap类加载器去加载.因此Object类在程序的各种类加载器环境中都是同一个类.相反.如果没有使用双亲委派模型.由各个类加载器自行去加载的话.如果用户编写了一个称为“java.lang.Object”的类.并存放在程序的ClassPath中.那系统中将会出现多个不同的Object类.java类型体系中最基础的行为也就无法保证.应用程序也将会一片混乱.
public class Test {
public static void main(String[] args) {
// 打印的是JVM可以使用的最大的内存
System.out.println(Runtime.getRuntime().maxMemory() + "字节" + Runtime.getRuntime().maxMemory()/1024/1024/1024 + "GB" );
// Returns the total amount of memory in the Java virtual machine
System.out.println(Runtime.getRuntime().totalMemory() + "字节" + (double)Runtime.getRuntime().totalMemory()/1024/1024/1024 + "GB" );
// 返回JVM的空余内存
System.out.println(Runtime.getRuntime().freeMemory() + "字节" + (double)Runtime.getRuntime().freeMemory()/1024/1024/1024 + "GB");
}
}
我的电脑是8G的内存,不修改JVM参数,默认情况下分配的堆内存的最大和总的大小是下面这样:
1857028096字节 1GB 占内存的八分之一
126877696字节0.1181640625GB 占电脑内存的六十四分之一
123521984字节0.11503881216049194GB 占电脑内存的六十四分之一
参数名称 | 含义 | 默认值 | |
---|---|---|---|
-Xms | 初始堆大小 | 物理内存的1/64(<1GB) | 默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制. |
-Xmx | 最大堆大小 | 物理内存的1/4(<1GB) | 默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制 |
-Xmn | 年轻代大小(1.4or lator) | 注意:此处的大小是(eden+ 2 survivor space).与jmap -heap中显示的New gen是不同的。 整个堆大小=年轻代大小 + 年老代大小 + 持久代大小. 增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8 | |
-XX:NewSize | 设置年轻代大小(for 1.3/1.4) | ||
-XX:MaxNewSize | 年轻代最大值(for 1.3/1.4) | ||
-XX:PermSize | 设置持久代(perm gen)初始值 | 物理内存的1/64 | |
-XX:MaxPermSize | 设置持久代最大值 | 物理内存的1/4 | |
-XX:+PrintGCDetails | 输出形式:[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs] [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs] |
生命周期: 生命周期起点是当一个java应用main函数启动时虚拟机也同时被启动,而只有当在虚拟机实例中的所有非守护进程都结束时,java虚拟机实例才结束生命。
java虚拟机与main方法的关系:main函数就是一个java应用的入口,main函数被执行时,java虚拟机就启动了。启动了几个main函数就启动了几个java应用,同时也启动了几个java的虚拟机。
一、首先分析两个概念
JVM实例和JVM执行引擎实例
(1)JVM实例对应了一个独立运行的java程序,它是进程级别。
(2)JVM执行引擎实例则对应了属于用户运行程序的线程,它是线程级别的。
二、JVM的生命周期
(1)JVM实例的诞生:当启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。
(2)JVM实例的运行 main()作为该程序初始线程的起点,任何其他线程均由该线程启动。JVM内部有两种线程:守护线程和非守护线程,main()属于非守护线程,守护线程通常由JVM自己使用,java程序也可以标明自己创建的线程是守护线程。
(3)JVM实例的消亡:当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。
GC如其名,就是垃圾收集,当然这里仅就内存而言。Garbage Collector(垃圾收集器)以应用程序的root为基础,遍历应用程序在Heap上动态分配的所有对象,通过识别它们是否被引用来确定哪些对象是已经死亡的、哪些仍需要被使用。已经不再被应用程序的root或者别的对象所引用的对象就是已经死亡的对象。
从计算机组成的角度来讲,所有的程序都是要驻留在内存中运行的。而内存是一个限制因素(大小)。除此之外,托管堆也有大小限制。因为地址空间和存储的限制因素,托管堆要通过垃圾回收机制,来维持它的正常运作,保证对象的分配,尽可能不造成“内存溢出”。
在Java中,是通过可达性分析(Reachability Analysis)来判定对象是否存活的。该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。
如上图所示,object1~object4对GC Root都是可达的,说明不可被回收,object5和object6对GC Root节点不可达,说明其可以被回收。
在Java中,可作为GC Root的对象包括以下几种:
之所以说标记/清除算法是几种GC算法中最基础的算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。标记/清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
标记阶段:标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的GC Roots对象,对从GC Roots对象可达的对象都打上一个标识,一般是在对象的header中,将其记录为可达对象;
清除阶段:清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header信息),则将其回收。
上图是标记/清除算法的示意图,在标记阶段,从对象GC Root 1可以访问到B对象,从B对象又可以访问到E对象,因此从GC Root 1到B、E都是可达的,同理,对象F、G、J、K都是可达对象;到了清除阶段,所有不可达对象都会被回收。
在垃圾收集器进行GC时,必须停止所有Java执行线程(也称"Stop The World"),原因是在标记阶段进行可达性分析时,不可以出现分析过程中对象引用关系还在不断变化的情况,否则的话可达性分析结果的准确性就无法得到保证。在等待标记清除结束后,应用线程才会恢复运行。
前面刚提过,后续的收集算法是在标记/清除算法的基础上进行改进而来的,那也就是说标记/清除算法有它的不足。其实了解了它的原理,其缺点也就不难看出了。
1、效率问题。标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是非常庞大的,这无疑很耗费时间,而且GC时需要停止应用程序,这会导致非常差的用户体验。
2、空间问题。标记清除之后会产生大量不连续的内存碎片(从上图可以看出),内存空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
复制算法每次都是对整个半区进行内存回收,这样就减少了标记对象遍历的时间,在清除使用区域对象时,不用进行遍历,直接清空整个区域内存,而且在将存活对象复制到保留区域时也是按地址顺序存储的,这样就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。
复制算法简单高效,优化了标记/清除算法的效率低、内存碎片多的问题。但是它的缺点也很明显:
1、将内存缩小为原来的一半,浪费了一半的内存空间,代价太高;
2、如果对象的存活率很高,极端一点的情况假设对象存活率为100%,那么我们需要将所有存活的对象复制一遍,耗费的时间代价也是不可忽视的。
基于以上复制算法的缺点,由于新生代中的对象几乎都是“朝生夕死”的(达到98%),现在的商业虚拟机都采用复制算法来回收新生代。由于新生代的对象存活率低,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的From Survivor空间、To Survivor空间,三者的比例为8:1:1。每次使用Eden和From Survivor区域,To Survivor作为保留空间。GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
每次清理完后 eden和To区是空的。
复制算法在对象存活率较高时要进行较多的复制操作,效率会变得很低,更关键的是,如果不想浪费50%的内存空间,就需要有额外的内存空间进行分配担保,以应对内存中对象100%存活的极端情况,因此,在老年代中由于对象的存活率非常高,复制算法就不合适了。根据老年代的特点,高人们提出了另一种算法:标记/整理算法。从名字上看,这种算法与标记/清除算法很像,事实上,标记/整理算法的标记过程任然与标记/清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。
可以看到,回收后可回收对象被清理掉了,存活的对象按规则排列存放在内存中。这样一来,当我们给新对象分配内存时,jvm只需要持有内存的起始地址即可。标记/整理算法不仅弥补了标记/清除算法存在内存碎片的问题,也消除了复制算法内存减半的高额代价,可谓一举两得。但任何算法都有缺点,就像人无完人,标记/整理算法的缺点就是效率也不高,不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
弄清了以上三种算法的原理,下面我们来从几个方面对这几种算法做一个简单排行。
效率:复制算法 > 标记/整理算法 > 标记/清除算法(标记/清除算法有内存碎片问题,给大对象分配内存时可能会触发新一轮垃圾回收)
内存整齐率:复制算法 = 标记/整理算法 > 标记/清除算法
内存利用率:标记/整理算法 = 标记/清除算法 > 复制算法
分代收集算法的原理是采用复制算法来收集新生代,采用标记/清理算法或者标记/整理算法收集老年代。
当前商业虚拟机都采用分代收集算法,说它是终极算法,是因为它结合了前几种算法的优点,将算法组合使用进行垃圾回收,与其说它是一种新的算法,不如说它是对前几种算法的实际应用。分代收集算法的思想是按对象的存活周期不同将内存划分为几块,一般是把Java堆分为新生代和老年代(还有一个永久代,是HotSpot特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法。
理算法或者标记/整理算法收集老年代。
当前商业虚拟机都采用分代收集算法,说它是终极算法,是因为它结合了前几种算法的优点,将算法组合使用进行垃圾回收,与其说它是一种新的算法,不如说它是对前几种算法的实际应用。分代收集算法的思想是按对象的存活周期不同将内存划分为几块,一般是把Java堆分为新生代和老年代(还有一个永久代,是HotSpot特有的实现,其他的虚拟机实现没有这一概念,永久代的收集效果很差,一般很少对永久代进行垃圾回收),这样就可以根据各个年代的特点采用最合适的收集算法。
在新生代中每次垃圾回收都发现有大量的对象死去,只有少量存活,因此采用复制算法回收新生代,只需要付出少量对象的复制成本就可以完成收集;而老年代中对象的存活率高,不适合采用复制算法,而且如果老年代采用复制算法,它是没有额外的空间进行分配担保的,因此必须使用标记/清理算法或者标记/整理算法来进行回收。