学习前的问题
1.为什么要学习JVM?
按照过来人的说法:与其现在学习很多框架,还不如先学好java基础,不然我们学再多也只会一脸迷茫。要学好Java基础,最重要要学好JDK!
JDK主要包含了三部分,第一部分就是JVM,此外,第二部分就是Java的基础类库。最后,第三部分就是Java的开发工具包。
2.JDK、JRE、JVM三者间的关系
JVM :英文名称(Java Virtual Machine),就是我们耳熟能详的 Java 虚拟机。它只认识 xxx.class 这种类型的文件,它能够将 class 文件中的字节码指令进行识别并调用操作系统向上的 API 完成动作。所以说,jvm 是 Java 能够跨平台的核心。
JRE :英文名称(Java Runtime Environment),我们叫它:Java 运行时环境。它主要包含两个部分,jvm 的标准实现和 Java 的一些基本类库。它相对于 jvm 来说,多出来的是一部分的 Java 类库。
JDK :英文名称(Java Development Kit),Java 开发工具包。jdk 是整个 Java 开发的核心,它集成了jre 和一些好用的小工具。例如:javac.exe,java.exe,jar.exe 等。
显然,这三者的关系是:一层层的嵌套关系。JDK>JRE>JVM。
开始学习
一、类装载器(ClassLoader)
1、是什么?
负责加载class文件,class文件在文件开头有特定的文件标示 [1],将class文件字节码内容加载到内存中,并将这些内容转化为方法区中的运行时数据结构并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine(执行引擎)决定
.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象(Class Class<T>),用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的 Class对象, Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
2、有几种?
虚拟机自带的加载器:
启动类加载器( Bootstrap ClassLoader):C++开发,负责加载存放在 JDK[2]\jre\lib下,能被虚拟机识别的类库(如rt.jar,所有的java.开头的类均被 BootstrapClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
扩展类加载器( Extension ClassLoader):该加载器由 sun.misc.Launcher$ExtClassLoader实现,它负责加载 JDK\jre\lib\ext目录中,或者由 java.ext.dirs系统变量指定的路径中的所有类库(如javax.开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器(ApplicationClassLoader):该类加载器由 sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器。
用户自定义加载器:java.lang.ClassLoader的子类,用户自定义加载方式
例子:
public class MyObject{
public static void main(String[] args){
Object object =new Object();
System.out.println(object.getClass().getClassLoader());
MyObject myobject =new MyObject();
System.out.println(myobject.getClass().getClassLoader().getParent().getParent());
System.out.println(myobject.getClass().getClassLoader().getParent());
System.out.println(myobject.getClass().getClassLoader());
}
}
输出结果为:(Bootstrap ClassLoader由C++编写,所以这里结果为null)
3、双亲委派
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
1、当 AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
2、当 ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
3、如果 BootStrapClassLoader加载失败(例如在$JDK/jre/lib里未查找到该class),会使用 ExtClassLoader来尝试加载;
4、若ExtClassLoader也加载失败,则会使用 AppClassLoader来加载;
5、如果 AppClassLoader也加载失败,则会报出异常 ClassNotFoundException。
双亲委派模型意义:
保证沙箱安全,系统类防止内存中出现多份同样的字节码
保证Java程序安全稳定运行
二、内存结构
以上图中,灰颜色(Java栈,本地方法栈,程序计数器)是运行时线程私有的内存区域,且不存在垃圾回收;而橙颜色(方法区、堆)是所有线程共享的内存区域
1、本地方法栈(Native Method Stack)
本地方法接口的作用是融合不同的编程语言为Java所用,初衷是融合C/C++程序,Java诞生时在C/C++的淫威下专门开辟了一块区域处理标记为native的代码,它的具体做法是Native Method Stack登记native方法,在Execution Engine执行时加载native libraries。
例如线程调用start方法时,要调用底层操作系统,在Thread类的源码中有以下代码:只有方法声明,没有方法实现
private native void start0();
2、程序计数器(Program Counter Register)
Register在计算机英语中指寄存器,因此程序计数器也可以叫PC寄存器
每一个线程都有一个PC寄存器,是线程私有的,它就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指针的地址),由执行引擎读取下一条指令,是一个非常小的内存空间。
如果执行的是一个Native方法,那么这个计数器是空的。
此内存区域是唯一一个在Java虚拟机规范中没有规定任何内存溢出错误(OutOfMemoryError)情况的区域。
3、Java栈(Java stack)【栈管运行,堆管存储】
栈也叫栈内存,主管Java程序的运行,在线程创建时创建,它的生命周期也是跟随线程的生命周期,线程结束栈内存就释放,是线程私有的,栈不存在垃圾回收问题;8种基本类型的变量+对象的引用变量+实例方法都在函数的栈内存中分配
例:int+Person+add和main
public class Test{
public int add(int x,int y)
{
int result = -1;
result =x+y;
return result;
}
public static void main(String[] args)
{
Person p1 = new Person();
add(2,3);
}
}
栈保存什么?
①本地变量:输入参数(x,y)和输出参数(result)以及方法内的变量(result,p1);
②栈操作:记录出栈、入栈操作;
③栈帧数据:包括类文件、方法等(main方法压入栈底,add方法在栈顶)
栈运行原理(java方法=栈帧)
栈中的数据都是以栈帧的格式存在,栈帧是一个内存区块,是一个有关方法和运行期数据的数据集
例如:当A方法被调用就产生一个栈帧F1,A调用B又产生栈帧F2,B调用C又产生栈帧F3·····执行完毕后,先弹出F3,再F2,再F1,符合先进后出
4、堆(heap)
Java堆(Java Heap)是Java虚拟机所管理的内存最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存,实例对象98%属于临时对象(Eden区分配)
堆内存物理上分为:新生+养老
新生区(Eden:From:To=8:1:1):养老区=1:2
堆内存逻辑上分为:新生+养老+永久代/元空间(对应方法区)
永久区(java7之前有)存储是一个常驻内存区域,用于存放JDK自身携带的Class,Interface的元数据,也就是运行环境必须有的类信息(rt.jar),此区域的数据不会被垃圾回收掉,关闭JVM才会释放此区域内存。
5、方法区(Method Area)
实际而言,方法区与堆一样,是供各线程共享的运行时的内存区域,它存储了每个类的结构信息(类的模板Class),用于储存虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码。虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却有一个别名叫Non-Heap(非堆),目的就是要和堆分开。
永久代是方法区的一个实现:对于HotSpot虚拟机,很多开发者习惯将方法区称为“永久代”,但严格本质上说两者不同,或者说使用永久代来实现方法区而已,也就是说方法区是规范,在不同虚拟机里实现是不一样的,最典型是永久代(PermGen space)和元空间(Metaspace)。
但实例变量[3]存在堆内存中,与方法区无关
栈+堆+方法区的交互关系
(Person p1在栈中,new Person()在堆中,Person Class在方法区)
HotSpot[4]是使用指针的方法来访问对象;
reference存储的就直接是对象的地址(实例变量);
Java堆中会存放访问类元数据(也就是Class,在方法区中)的地址
三、堆参数调优
JVM调优实际上就是堆内存的调优,java堆内存默认只用本机内存的1/4,如果java所占内存不够就需要调优
引言
在java8中,永久代已经被移除,被元空间取代。
元空间与永久代的最大区别:
永久代使用JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本地内存。
因此,元空间的大小仅受本地内存限制。类的元数据放在native memory(本地内存),字符串池和类的静态变量放在java堆中,这样可以加载多少类的元数据不再由MaxPermSize控制,而是系统的实际可用空间来控制。
1、简介
参数 | 意义 |
---|---|
-Xms | Java堆初始分配内存,默认为内存的1/64 |
-Xmx | Java堆最大分配内存,默认为内存的1/4 |
-Xmn | 新生去分配内存,默认为java堆的1/3 |
-XX:PermSize | 永久代的初始大小,几乎不会再用 |
-XX:MaxPermSize | 永久代的最大大小,几乎不会再用 |
-XX:+PrintGCDetails | 输出详细的GC处理日志 |
public static void main(String[] args)
{
long maxMemory = Runtime.getRuntime().maxMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println("最大内存:"+(maxMemory/(double)1024)/1024+"MB");
System.out.println("初始内存:"+(totalMemory/(double)1024)/1024+"MB");
}
JVM运行时数据区(绿区)被抽象为Runtime对象
已知本机的可用内存为7.9G,由java堆内存的分配规律,输入以下代码,得到:
最大内存:1792.0MB(大概1/4),初始内存:121.0MB(大概1/64)
2、如何进行JVM的内存配置
以IDEA为例,Run->Edit Configurations->
有关用于元数据的空间的信息包含在堆的打印输出中
生产环境中ms一般设置成跟mx相等,避免GC和应用程序争抢内存,理论值忽高忽低,造成停顿
以下两种情况会出现堆内存溢出的错误:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
说明出现堆内存溢出,原因有二:
①Java虚拟机的堆内存不够,可以通过-Xms、-Xmx调整
②代码创建大量对象,且长时间不能被垃圾收集器收集(存在被引用)
//不断new对象,让GC的速度跟不上new对象的速度,养老区Full GC实在腾不出位置
String str = "Agnes";
while (true){
str += str + new Random().nextInt(111)+ new Random().nextInt(222);
}
//new一个比java最大内存还大的对象
byte[] bytes = new byte[11*1024*1024];
四、GC
在C++中,创建出的对象是需要手动去delete掉的。我们Java程序运行在JVM中,JVM可以帮我们“自动”回收不需要的对象,对我们来说是十分方便的。
1、Heap堆new对象过程
MinorGC过程:复制->清空->互换
①Eden、SurvivorFrom复制到SurvivorTo,年龄+1
所有对象都是在伊甸区被new出来,当伊甸区空间用完时会触发第一次GC,将Eden不再被其他对象引用的对象销毁,然后将Eden剩余对象移到幸存0区(Eden->from);再次触发GC时会扫描Eden区和from区,对他们进行销毁,再把存活的对象复制到To区(如果年龄到达老年标准,复制到养老区),同时年龄+1(Eden+From->To)
②清空Eden,SurvivorFrom
此时幸存者都在To区,就要清空Eden和from区
③SurvivorTo区和SurvivorFrom区互换
最后,SurvivorTo区和SurvivorFrom区互换,原来To区成为下一次GC的From区,部分对象会在from和to区间复制来复制去,交换15次(由MaxTenuringThreshold参数决定,默认15),最终进入养老区(To<->From,则下一次GC From区有对象,To区没有,即谁空谁是To)
养老区满了就触发Major GC(FullGC),若养老区还无法清理对象,腾出位置,就会产出OOM异常。
Minor GC与Full GC的区别:
- 普通GC(Minor GC):只针对新生代区域,因为绝大大数java对象存活率不高,所以Minor GC非常频繁,回收速度也比较快。
- 全局GC(Full GC):发生在老年代,经常会伴随至少一次的Minor GC(不绝对),速度比Minor GC要慢10倍以上。
2、GC处理日志信息
规律:[名称:GC前内存占用->GC后内存占用[该区的内存总大小]]
[GC [PSYoungGen:274931K->10738K(274944K)] 371093K->147186K(450048K),0.0668480 secs]
[Times: user=0.17 sys=0.08, real=0.07 secs]
[Full GC [PSYoungGen: 10738K->0K(274944K)]
[ParOldGen: 136447K->140379K(302592K)] 147186K->140379K(577536K)
[PSPermGen: 85411K->85376K(171008K)], 0.6763541 secs]
[Times: user=1.75 sys=0.02, real=0.68 secs]
jdk11的GC回收日志信息可以参照https://www.jianshu.com/p/f1ceb0a35240,虽然不同但也能大致看懂
3、GC的四大算法
3.1 引用计数法
每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。
缺点:
- 每次对对象赋值时都要维护引用计数器,且计数器本身也有一定的消耗;
- 较难处理循环引用
3.2 复制算法(Copying)
年轻代中使用的是Minor GC,这种GC算法采用的是复制算法
原理:从根集合(GC Root)开始,通过遍历从from中找到存活对象,拷贝到To中;From和To交换身份。
优点:
- 没有标记和清除的过程,效率高
- 没有内存碎片
缺点:
- 浪费幸存者区一半空间
- 要求对象存活率要特别低
3.3 标记清除(Mark-Sweep)
老年代 所使用的垃圾回收算法
原理:算法分标记和清除两个阶段,先标出要回收的对象,然后统一回收这些对象
优点:
- 节约空间
缺点:
- 会产生内存碎片,空闲内存不连续
- 需要两次扫描,耗时间
3.4 标记压缩/整理(Mark-Compact)
就是标记清除压缩,老年代一般是由标记清除或标记清除和标记压缩混合实现
原理:首先与标记清除一样,接着再次扫描,并往一端滑动存活对象(就是为了整理标记清除产生的碎片)
优点:
- 没有产生碎片
缺点:
- 需要移动对象的成本,效率低
3.5 总结(分代收集算法)
内存效率:复制算法>标记清除>标记整理(效率只是简单的时间复杂度比对)
内存整齐度:复制算法=标记整理>标记清除
内存利用率:标记整理>标记清除>复制算法
次数上频繁收集Young区,较少收集Old区,基本不动元空间
年轻代特点是区域比老年代小,对象存活率低
老年代特点是区域大,对象存活率高
新生代用复制,老年代用标清,没有最好的算法,只有最适合的算法。