JVM简介
1)内存区域划分
执行流程
运行时数据区
一个运行起来的Java进程,就是一个JVM虚拟机,需要从内存中申请一块内存
方法区(1.7及以前)/元数据区(1.8开始)
存储类对象(类信息、常量、静态变量,及、即编译器编译后的数据)
.class文件加载到内存后
运行时常量池
运行时常量池是方法区的一部分,存放字面量与符号引用字面量 : 字符串 (JDK 8 移动到堆中 ) 、 final 常量、基本数据类型的值符号引用 : 类和结构的完全限定名、字段的名称和描述符、方法的名称和描述
堆存储代码中 new 的对象(占据内存最大的区域)
栈存储代码执行过程中,方法之间的调用关系每个元素,称为一个“栈帧”每个栈帧,代表了一个方法调用栈帧里包含了方法的入口,方法返回的位置,方法的形参,方法的返回值,局部变量...
程序计数器
一块比较小的空间,主要用来存放一个“地址”,表示下一条要执行的指令在内存中的哪个地方(方法区里,每个方法里面的指令,都是以二进制的形式保存到对应类对象里)
class Test{
public void a(){}
public void b(){}
}
方法a 和方法b会被编译成二进制指令,放到 .class 文件中
执行类加载的时候,就把 .class 文件里的内容,加载起来,放到类对象中
刚开始调用方法,程序计数器,记录的就是方法的入口的地址
随着一条一条的执行指令,每执行一条,程序计数器的值都会自动更新去指向下一条指令
如果当前线程正在执行的是一个 Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是一个 Native 方法,这个计数器值为空
本地方法栈
指的是使用 native 关键字修饰的方法
这个方法不用Java实现,而是在JVM内部通过C++代码实现的
JVM内部的C++代码的调用关系
总结:
1)虚拟机栈,程序计数器,本地方法栈,都是每个线程都有一份
2)堆区,元数据区,在JVM进程中只有一份
一个JVM进程,可能有多个线程
每个线程,有自己的 程序计数器 和 栈空间,这些线程共用一份 堆 和 方法区
可以说是,每个线程都有自己的私有空间
1)堆 存放new出来的对象
2)方法区/元数据区 存放类对象(类加载后存放的位置)
3)栈 存放方法之间的调用关系
4)程序计数器 存放每个线程,下一条要执行的指令的地址
1)2)整个Java进程共用一份 3)4)每个线程都有自己的一份
常见问题
class Test{
public int n = 100;
public static int a = 10;
}
void main(){
Test t = new Test();
} n,a,t ,new Test() 处于哪个区域
Test t = new Test();
t这个变量是一个引用类型的变量,存储的是一个对象的地址,不是对象本身
new出来的对象在堆上,同时有创建了一个局部变量 Test t (引用类型的变量),把地址存到 t 里
一个变量处于哪个区域,和变量的形态密切相关
局部变量 处于 栈 上
成员变量 处于 堆 上
静态变量(类属性) 处于 元数据区/方法区 里
2)类加载的过程
基本流程
java代码会被编译成.class文件(包含一些字节码),java程序要想运行起来,需要让JVM读取到这些.class文件,并把里面的内容构造成类对象,保存到内存的方法去中
“执行代码”就是调用方法,需要先知道每个方法,编译后生成的指令都是什么
官方文档把类加载的过程,主要分成了五个步骤(三个步骤):
1)加载找到.class文件,打开文件,读取文件内容往往代码中,会给定某个类的“全限定类名”,例如JVM会根据这个类名,在一些指定的目录范围内查找“ 加载 ” ( Loading )阶段是整个 “ 类加载 ” ( Class Loading )过程中的一个阶段,它和类加载 Class Loading 是不同的,一个是加载 Loading 另一个是类加载 Class Loading ,所以不要把二者搞混了在加载 Loading 阶段, Java 虚拟机需要完成以下三件事情:1 )通过一个类的全限定名来获取定义此类的二进制字节流2 )将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
2)验证
验证是连接阶段的第一步.class文件是一个二进制的格式(某个字节具有特定的含义),需要验证当前读取的这个格式是否符合要求
3)准备
给类对象分配内存空间(最终目标是构造出类对象)
此处只是分配内存空间,并没有进行初始化,这个空间上的内存的值全都是0值
(此时类的 static 成员全是0值)
4)解析
针对类对象中包含的字符串常量进行处理,进行一些初始化操作
java代码中用到的字符串常量,在编译之后,也会进入到.class文件中
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程final String s = "hello";
此时,.class文件的二进制指令中,会有一个 s 这样的引用被创建出来
由于引用本质上保存的是一个变量的地址,在.class文件中(文件中不涉及到内存地址)
因此,在.class文件中,s 的初始化语句,就会先被设置成一个“文件的偏移量”,通过偏移量,就能找到“hello”这个字符串所在的位置
当这个类真正被加载到内存中的时候,再把这个偏移量,替换回真正的内存地址
在.class文件中会有一条指令,这条指令就描述了 String s = @100(偏移量)
“hello”字符串距离文件开头的长度是100字节
把字符串的真实地址,替换成文件偏移量的过程,就是“解析阶段”的主要工作
也叫做,把“符号引用”(文件偏移量)替换成“直接引用”(内存地址)
5)初始化
初始化阶段, Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程针对类对象进行初始化,把类对象中需要的属性设置好——>初始化static成员,执行静态代码块,加载父类...
双亲委派模型
属于类加载中,第一个步骤 “加载”过程中的一个环节如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载此处 “父子” 关系,不是继承关系这几个 ClassLoader 里面有一个 parent 属性,指向了一个 “父加载器”
类加载器,JVM内置了三个类加载器:
1) BootStrap ClassLoader
负责的是标准库的目录
2) Extension ClassLoader
负责的是JDK中一些扩展的库
3) Application ClassLoader
负责的是搜索项目当前目录和第三方库对应目录
程序员也可以自己手动创造新的类加载器
类加载的过程(找.class文件的过程):
1)给定一个类的全限定类名,例如 java.lang.String
2)从 Application ClassLoader 作为入口,开始执行查找的逻辑
3)Application ClassLoader ,不会立即去扫描自己负责的目录,而是把查找的任务,交给它的父加载器 Extension ClassLoader
4)Extension ClassLoader,也不会立即扫描自己负责的目录,而是把查找的任务,交给它的父加载器 BootStrap ClassLoader
5)BootStrap ClassLoader,也不会立即扫描自己负责的目录,也想把它交给自己的父加载器,结果发现它自己没有父加载器。因此,BootStrap ClassLoader只能扫描自己负责的目录
6)没有扫描到,就会回到 Extension ClassLoader,Extension ClassLoader就会扫描自己负责的目录。如果找到,就执行后续的类加载操作,此时查找过程结束;如果没找到,把任务交给 Application ClassLoader 执行
7)没有扫描到,就会返回到 Application ClassLoader,Application ClassLoader 会扫描自己负责的目录。如果找到,就执行后续的类加载操作;如果没找到,就会抛出一个 ClassNotFoundException
双亲委派模型,就是一个 查找优先级 问题——>
确保 标准库的类,被加载的优先级最高,其次是 扩展库,其次是 自己写的库和第三方库
(若自己实现了一个 java.lang.String,JVM加载的是标准库的类)
双亲委派模型的打破:
如果自己编写一个类加载器,就不一定要遵循上述的流程
例如Tomcat里,加载 webapp 时用的就是自定义的类加载器
只能在 webapp 指定的目录中查找,找不到就直接抛异常(不会去标准库中查找)
优点
3)垃圾回收机制
GC => 垃圾回收GC回收的目标,是内存中的对象对于Java来说,就是new出来的对象栈里的局部变量,是跟着栈帧的生命周期走的(方法执行结束,栈帧销毁,内存释放)静态变量,生命周期就是整个程序,始终存在,意味着静态变量是无需释放的真正要需要GC释放的 堆 上的对象
垃圾的判断算法
在GC中,有如下两个主流的方案:
1. 引用计数(Python,PHP)
new出来的对象,单独安排一块空间,来保存一个计数器
{
Test t = new Test();
Test t2 = t1;
}
出{}之后,t1和t2就销毁了,引用计数就为0了
保存引用计数,描述有这个对象有几个引用指向它
在Java中,使用对象,必须依靠引用
如果一个对象,没有使用引用指向,就可以视为 垃圾 了(引用计数为0)
缺点:
1)比较浪费内存
如果对象很小,计数器占据的空间就难以忽视了
2)存在“循环引用”问题
class Test{
public Test t;
}
Test a = new Test();
Test b = new Test();
a.t = b;
b.t = a;
a = null;
b = null;
此时,a 和 b两个引用已经销毁了
new出来的两个对象,无法被其他的代码访问到,但是它们的引用计数不是0,所以不能被回收
此时,第一个对象,引用了第二个对象;第二个对象,引用了第一个对象——>类似死锁
2. 可达性分析
本质上是时间换空间
有一个/一组线程,周期性的扫描代码中的对象
从一些特定的对象出发,尽可能的进行访问的遍历,把所有能访问到的对象,都标记成“可达”;反之,经扫描后,未被标记到的对象,就是 垃圾
此处的遍历大概率是N叉树,看访问的某个对象,里面有多少个引用类型的成员,针对每个引用类型的成员都需要进行进一步的便利
此处的可达性分析都是周期性的——>可达性分析比较消耗系统资源,开销比较大
垃圾回收算法
三种基本思路
1)标记清除
总的空闲空间是 2MB,但是申请空间时,只能申请 <= 1MB的空间
把对应的对象,直接释放掉——>会产生很多“碎片”
释放内存,目的是让别的代码能够申请
申请内存,都是申请到“连续”的内存
2)复制算法
把内存分成两份,一次只用其中的一半
通过复制的方式,把有效对象归类到一起,再统一释放剩下的空间
这个方案可以解决内存碎片的问题,但缺点依然很明显:
1. 内存要浪费一半,利用率不高
2. 如果有效对象很多,拷贝开销很大
3)标记整理
既能解决内存碎片的问题,又能处理重复算法中利用率问题
类似于顺序表删除元素的搬运操作——>搬运的开销依然很大
具体实现
伊甸区 存放刚 new 出来的对象
经验规律:从对象诞生开始,到第一轮可达性分析的过程中,虽然时间不长(往往就是毫秒—秒),但是,在这个时间段内,大部分的对象都会变成垃圾
1)伊甸区—>幸存区 复制算法
每一轮GC扫描之后,都会把有效对象复制到幸存区中,伊甸区就可以整个释放了
由于经验规律,真正需要复制的对象不多,非常适合复制算法
幸存区 分成大小相等的两块,每次只用一块(复制算法的体现)
2)GC扫描线程也会扫描幸存区,会把活过GC扫描的对象(扫描过程中可达)拷贝到幸存区的另一个部分
幸存区之间的拷贝,每一轮会拷贝多个对象,每一轮也会淘汰多个对象(有些对象随着时间的推移,就成垃圾了)
3)当这个对象已经在幸存区存活过很多轮GC扫描后,JVM就认为这个对象,短时间内应该是释放不掉了,就会把这个对象拷贝到 老年代
4)进入老年代的对象,也会被GC扫描,但是频率会比新生代低得多——>减少GC扫描的开销
经验规律,新生代的对象更容易成为垃圾,老年代的对象更容易存活
分代回收——> 对象活过的GC扫描越多,就越老新生代,主要使用 复制算法老年代,主要使用 标记整理分代回收,是JVM中主要的回收的思想方法但是在垃圾回收具体实现的时候,可能会有一些调整和优化