引言
刚入门java的时候,很难理解jvm到底是啥,一个类从编写到运行,经历了什么?类、方法、变量都存在了哪里?到底什么是类加载器?什么是堆?什么是栈?什么是栈溢出?什么是堆溢出?今天就来整理一下我的学习笔记
可以观察一下此图,紫色部分都是线程私有的,每个线程独占此内存区域.橘黄色部分是线程共享的.
1.类加载器
负责加载class文件,class文件在文件开头有特定的文件标示,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构并且classloader只负责class文件的加载,至于它是否可以运行,则由execution engine(执行引擎)决定 .
接下来我们来了解一下类的“加载、链接、初始化”,当一个类被声明的时候,都经历了什么:
加载 :
查找并加载类的二进制数据
链接:
1. 验证 : 确保被加载的类的正确性
2. 准备 : 为类的静态变量分配内存,并将其初始化为默认值
3. 解析 : 把类中的符号引用转换为直接饮用
初始化 :
为类的静态变量赋予正确的初始值
eg:
private static int a = 5;
在第二步 准备 阶段,对a赋默认值 a = 0
在 初始化 阶段把 5 赋值给a a = 5
类的加载:
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后再内存中创建一个java.lang.Class对象(规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区中)用来封装类在方法区内的数据结构.
加载.class文件的额方式有五种:
- 从本地系统中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专用数据库中提取.class文件
- 将java源文件动态编译为.class文件(动态代理)
Test1
小测试,下面程序输出结果是啥 ?
public class MyTest{
public static void main(String[] args){
System.out.print(MyChild.str);
System.out.print("****************************");
System.out.print(MyChild.str2);
}
}
class MyParent1{
public static String str = "hello world";
static{
System.out.println("MyParent1 static block");
}
}
class MyChild extends MyParent1{
public static String str2 = "welcome";
static{
System.out.print("MyChild static block")
}
}
结果:
MyParent1 static block
hello world
****************************
MyParent1 static block
MyChild static block
welcome
结论:
对于静态字段来说,只有直接定义了该字段的类才会被初始化.
子类初始化,其所有的父类也将初始化.
Test2
再来看看这个输出啥 ?
public class MyTest2{
public static void main(Stromg[] args){
System.out.println(MyParent2.str);
}
}
class MyParents2{
public static final String str = "hello world";
static{
System.out.println("MyParent2 static block");
}
}
结果:
hello world
结论:
常量在编译阶段会存入调用这个常量的方法所在的类的常量池当中
本质上,调用类并没有直接引用到定义常量的类,因此不会触发定义常量的类初始化
将常量放在了MyTest2的常量池中.之后MyTest2和MyParents2就没有任何关系
甚至 我们可以将MyParents2的class文件删除
Test3
public class MyTest3 {
public static void main(String[] args){
System.out.println(MyParent3.str);
}
}
class MyParent3{
public static final String str = UUID.randomUUID().toString();
static{
System.out.println("MyParent3 static code");
}
}
结果:
MyParent3 static code
518443f5-8696-4dcc-be78-77f8039072ff
结论:
当一个常量的值并非编译期间可以确定的,那么其值就不会被放到调用类的常量池中,这时在程序运行时,会导致主动使用这个常量所在的类,
显然会导致这个类被初始化.
Test4
public class MyTest4{
public static void main(String[] args){
System.out.print(MyChild.b)
}
}
interface MyParent5{
public static int a = 5;
}
interface MyChild extend MyParent5{
public static int k = 6;
}
结果:
6
结论:
当一个接口在初始化时,并不要求其父接口都完成初始化
只有真正使用到父接口的时候(如饮用接口中所定义的常量时),才会初始化
接口中的常量默认 final
Test5
public class MyTest5{
public static void main(String[] args){
Singleton singleton = Singleton.getIntance();
System.out.print(singleton.counter1);
System.out.print(singleton.counter2);
}
}
class Singleton{
public static int counter1;
public static int counter2 = 0;
private static Singleton = new Singleton();
private Singleton(){
counter1++ ;
counter2++ ;
}
public static Singleton getIntance(){
return Singleton;
}
}
结果:
1 1
结论:
当单例类初始化之后,对counter1,counter2赋初始值0;然后 ++
废话了一堆,让我们切入正题 !
有两种类加载器,分别是:
-
Java虚拟机自带的加载器
- 根类加载器(Bootstrap)
- 扩展类加载器(Extension)
- 系统(应用)类加载器(System)
-
用户自定义的类加载器
- Java.lang.ClassLoader的子类
- 用户可以定制类的加载方式
什么是双亲委派机制 ?
在Java中任意一个类都是由这个类本身和加载这个类的类加载器来确定这个类在JVM中的唯一性。也就是你用你A类加载器加载的com.aa.ClassA和B类加载器加载的com.aa.ClassA它们是不同的,也就是用instanceof这种对比都是不同的。所以即使都来自于同一个class文件但是由不同类加载器加载的那就是两个独立的类。
- 双亲委派的意思是如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。
- 只有当父类加载器反馈自己无法完成这个请求的时候(在他的加载路径下没有找到所需要加载的Class),子类加载器才会尝试自己去加载
- 采用双亲委派的一个好处就是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象
2.Native Interface本地方法接口 :
- 本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合 c/c++ 程序,Java诞生的时候是c/c++横行的时候,想要立足.必须有调用c/c++程序,于是就在内存中专门开辟了一块区域处理标记为native代码,它的具体做法是Native Method Stack中登记 native方法,在Excution Engine执行时加载native libraies.
- 目前该方法使用的越来越少,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见. 因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等.
3.Native Method Stack(本地方法栈)
它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库
(为什么native方法只有声明,没有实现 ? )
答: 方法的装载和运行在栈中,native方法栈,实现到对应语言底层去处理
4.程序计数器 (pc寄存器):
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计.
这块内存区域很小,他是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取吓一跳需要执行的字节码指令
如果执行的是一个Native方法,那这个计数器是空的
用以完成分支、循环、跳转、异常处理、程序恢复等基础功能,不会发生内存溢出(OutOfMemory=OOM)错误
5.方法区:(所有线程共享,存在垃圾回收)
- 供各个线程共享的运行时内存区域. 它储存了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool) 、字段和方法数据、构造函数和普通方法的字节码内容 .
- 方法区是规范,在不同虚拟机里面实现是不一样的,最典型的就是永久带和元空间
- 实例变量存在堆内存中,和方法区无关
public class JVMSomething{
//实例方法
public void sayHello(){
}
//全局方法
public static void hi(){}
}
6.Stack栈:(线程私有,无oom,栈管运行,堆管存储)
栈 (FILO 先进后出)
栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,它的生命期时跟随线程的生命期,线程结束栈内存也就是放,对于栈来说不存在垃圾回收问题,只要线程一结束栈就Over,生命周期和线程一致,是线程私有的. 8种基本类型的变量+对象的引用变量+实力方法都是在函数的栈内存中分配 .
栈存储什么 :
本地变量(Local Variables):输入参数和输出参数以及方法内的变量;
栈操作(Operand Stack) : 记录出栈、入栈的操作;
栈帧数据 (Frame Data):包括类文件、方法等等 .
栈运行原理:
- 栈中的数据都是以栈帧(Stack Frame)的格式存在的,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧F1.并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈,
- 执行完毕后,先弹出F3栈帧,再弹出F2栈帧,再弹出F1栈帧,遵循 先进后出,后进先出 原则
- 每个方法执行的同时都会创建一个栈帧,用于存储局部变量表(方法内和形参中定义的)、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程.栈的大小和具体JVM的实现有关,通常在256k~756k之间,约等于1Mb左右
模拟StackOverflowError 栈溢出 :
public class MyObject {
public static void m(){
m();
}
public static void main(String[] args){
m();
}
}
结果:
Exception in thread "main" java.lang.StackOverflowError
at com.xk.evisu.jvm.MyObject.m(MyObject.java:12)
at com.xk.evisu.jvm.MyObject.m(MyObject.java:12)
at com.xk.evisu.jvm.MyObject.m(MyObject.java:12)
at com.xk.evisu.jvm.MyObject.m(MyObject.java:12)
....
栈溢出 (不是Exception,是Error)
7.堆 (线程共享 存在oom)
一个jvm实例只存在一个堆内存,堆内存的大小是可以调节的. 类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行,堆内存分为三部分: 新生代、老年代、元空间(java8之后)
物理结构 : 新生代+老年代
逻辑结构 : 新生代+老年代+元空间
新生代:
新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命.新生区又分为两部分:Eden、Survivor space,所有的类都是在Eden区被new出来的. Survivor区有两个: 0区和1区 .当Eden的空间用完时,程序有需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中移到S0区,如果S0区满了,则对该区进行垃圾回收,然后移动到S1区 . 如果S1区也满了,移到老年区 . 老年区满了的话,这时候将产生MajorGC(FullGC),进行老年区的内存清理.若老年区执行了Full GC之后仍然无法进行对象的保存,就会产生OOM.
如果出现oom,说明java虚拟机的堆内存不够,原因有二:
- java虚拟机的堆内存设置不够,可以通过修改参数-Xms、-Xmx来调整
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器手机(存在被引用)
- Eden : ( new Person()所在地 ) GC = YGC(young gc) = 轻gc Eden基本全部清空
- Survivor0 Space : from
- Survivor1 Space : to
s0 和 s1在gc之后会交换,谁为空谁换做s1
所占大小比例 :
Eden : s0 : s1 ------> 8:1:1
新生代 : 老年代 -----> 1:2
MinorGC过程(复制-->清空-->交换)
- Eden、SurvivorFrom地址到SurvivorTo,年龄+1
首先,当eden区满的时候会出发第一次GC,把还活着的对象拷贝到s0(from),当eden区再次触发GC的时候会扫描eden区和s0区域,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到s1(to)区域(如果有对象的年龄已经达到了老年代的标准,则复制到老年代).同时把这些对象的年龄+1 - 清空eden、s0
然后,清空eden和s0中的对象,也即复制之后有交换,谁空谁是s1 - s1和s0互换
最后survivorTo和survivorFrom互换,原survivorTo成为下一次GC时的SurvivorFrom区,部分对象会在From和To区域中复制来复制区,如此交换15次,(由jvm参数MaxTenuringThreshld决定,这个参数默认是15)最终如果还是存活,就进入老年代
老年代
- Old区满了之后,开启 Full Gc = FGC
- FULL GC之后 老年区仍不能清除空间 ----> OOM
实际而言,方法区和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的: 类信息+普通常量+静态常量+编译器编译后的代码等等.虽言jvm规范将方法区描述为一个逻辑部分,但它却还有一个别名叫 Non-heap(非堆),目的就是要和堆分开.
对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代”,但严格本质上说两者不同,或者说使用永久代来实现方法区而已,永久代是方法区的一个实现
元空间:
元空间在JAVA8之前叫做永久存储区,是一个常驻内存区域,用于存放JDK自身所携带的Class.Interface的元数据,也就是说它存储的是运行环境必须的类信息,被装载紧此区域的数据是不会被垃圾回收器回收掉的,关闭JVM才会释放此区域所占用的内存.
8.堆、栈、方法区的关系:
一个在栈中的引用类型(z1) ---------> new出来的实例对象在堆中(类的结构信息的地址) --------> 堆中对象的类模版信息在方法区中(类的结构信息)
9.垃圾回收
在Java8中,永久代已经被移除,被一个称为元空间的区域所取代,元空间的本质和永久代类似
元空间与永久代之间最大的区别在于:
永久代使用的是jvm的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存
因此,默认情况下,元空间的大小仅受本地内存限制.类的元数据放入native memory,字符串池和类的静态变量放入java堆中,这样可以加载多少类的元数据就不再由MaxPermSize控制,而由系统的实际可用空间开控制
-Xmx = -Xms 因为 : 避免gc和应用程序争抢内存
模拟oom
-Xms1m -Xmx1m -XX:+PrintGCDetails
public static void main(String[] args) throws InterruptedException {
System.out.println(Runtime.getRuntime().maxMemory()/(double)1024/1024);
System.out.println(Runtime.getRuntime().totalMemory()/(double)1024/1024);
System.out.println(305664/1024+699391/1024);
byte[] bytes = new byte[40*1024*1024];
}