JVM分为五大区域:
- 程序计数器;
- 虚拟机栈;
- 堆;
- 方法区;
- 本地方法栈;
程序计数器
当前线程的行号指示器。
是唯一没有OOM的区域,如果当前执行在Native方法,则返回为undefinded。
虚拟机栈
线程私有,生命周期和线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同事都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每个方法从调用到执行完毕就是一个栈帧在虚拟机栈中从入栈到出栈的过程。
局部变量表
存放了编译期可知的各种基本数据类型,对象引用和returnAddress类型(指向一条字节码指令的地址)。
64位长度的long和double类型的数据会占用2个局部变量空间(slot),其余只会占用一个。局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
本地方法栈
和虚拟机栈的作用非常相似,虚拟机栈是为了Java方法(字节码)服务,而本地方法栈为虚拟机使用到的Native方法服务。
Java堆
JVM内存管理中最大的一块。在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都会在这里分配内存。Java堆是垃圾收集器管理的主要区域(目前主要采用的是是分代回收)。
线程共享每只要是存放对象实例和数组。内存会划分出多个线程私有的分配缓冲区。是物理上不连续,但逻辑上连续。
方法区
属于共享内存区域,存储已经被虚拟机加载的类的信息,常量,静态变量,即时编译期编译后的代码等数据。
运行时常量池
属于方法区的一部分。Class文件中出了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
Java虚拟机对Class文件的每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储那种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对运行时常量池,Java虚拟机规范没有做任何细节的要求,可以自己实现。一般来说吗,出了保存Class文件中描述的符号引用外,还汇报翻译过来的直接引用也存储在运行时常量池中。
运行时常量池相对于Class文件常量池的另一个重要特征就是具备动态性,Java语言并不要求常量必须只能在编译器才能产生。比如编译期和运行期(String.intern())都可以放入常量池中。
直接内存
非虚拟机运行时数据区的部分。
垃圾回收机制
-
引用计数法
-
可达性分析法
当一个对象到GC Roots 没有任何引用链连接的时候说明对象并不可用。可以作为GC Roots的对象:
* 虚拟机栈(栈帧中的本地变量表)中引用的对象
* 方法区中类静态属性引用的对象
* 方法区中常量引用的对象
* 本地方法栈中JNI(NATIVE方法)引用的对象
强 -> 软 -> 弱 -> 虚
回收方法区
Java虚拟机确实说过可以不要求虚拟机在方法区实现垃圾回收,而且在方法去中进行的垃圾收集的”性价比“一般比较低;
永久代的垃圾收集主要分为两部分:废弃常量和无用的类。
回收废弃常量与回收Java堆中的对象很相似。假设一个”abc“常量在常量池中,那没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个”abc“常量就会被系统清理出常量池。(常量池中的其他类(接口)、方法、字段的符号引用也相似)
但判定一个类时无用的类就要同时满足如下的三个条件:
- 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对同事满足上述三个条件的无用类进行回收,但仅仅是可以,并不是和对象一样,不用了就一定会回收。
垃圾回收算法
标记清除算法
不足:效率不高;空间会产生大量的碎片
复制算法
把空间分成两块,每次只对其中的一块进行GC。当这块内存使用完时,就将还存活的对象复制到另外一块上面。
可以有效解决第一种方法导致的不足,但是会造成空间利用率低下。因为大多数新生代对象都不会熬过第一次GC。所以不需要将空间分为1:1。
可以将较大的一块Eden空间和两块较小的Survivor空间,每次使用一块Survivor和Eden空间。当回收时,将Eden和Survivor中还存货的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和Survivor空间。大小比例一般分配为8:1:1,每次之浪费10%的新Survivor空间。
但有一个问题就是 如果存活的对象占用的空间大于10%怎么办?
这里采用一种分配担保策略:多出来的对象直接进入老年代。
标记整理算法
不同于针对新生代的赋值算法,针对老年代的特点,创建了该算法。主要是把存活的对象移到内存的一端。
分代回收
根据存活对象划分几块内存区,一般是分为新生代和老年代。然后根据各个年代的特点制定相应的回收算法。
新生代
每次垃圾回收都有大量对象死去,只有少量存活,选用复制算法比较合理。
老年代
老年代中对象存活率较高,没有额外的空间分配对它进行担保。所以必须使用标记清除或者标记整理算法进行回收。
枚举根节点
OOMMAP
安全点
安全区域
垃圾收集器
todo
内存分配与回收策略
Java技术中所提倡的自动内存管理最终可以归结为自动化的解决了两个问题:给对象分配内存以及
回收分配给对象的内存。
对象的内存分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按照线程优先分配在TLAB上。少数情况也可能直接分配在老年代,分配的规则并不是百分之百固定的,七夕节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数配置。
新生代GC(Minor GC):只发生在新生代的垃圾收集动作,因为Java对象绝大多数都是朝生夕灭的特点,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC/Full GC):指在老年代发生的GC,出现了Major GC,经常会伴随着至少一次的Minor GC(但也并非绝对,在Parallel Scavenge收集器中就有直接进行Major GC的策略)。Major GC的速度一般会比Minor GC慢10倍以上。
- 首先对象优先在Eden分配;
- 大对象直接进入老年代;
- 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须要能识别那些对象该放在新生代和老年代中。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。对象每经过一次Minor GC依然存活,并且能够被Survivor容乃的话,将被移动代Survivor空间中,并且年龄+1。但他的年龄增加到一定程度(默认为15),就会被晋升到老年代中。但其实为了更好地适应不同程序的内存状况,虚拟机会动态判断对象年龄,虚拟机并不是必须要求对象的年龄达到了MaxTenuringThreshold才能晋升到老年代,当在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代了,无需等待满足条件。 - 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总和空间,如果成立,那么Minor GC是安全的。如果不成立,则虚拟机会查看空间分配担保设置是否允许担保失败。如果允许,那么继续检查老年代最大可用的连续空间是否大于历届晋升到老年代对象的平均大小,如果大于,则尝试进行一次Minor GC,尽管这次GC也是有风险的;如果小于,或者不允许空间分配担保失败,那么则改为进行一次Full GC。(一般空间分配担保都会打开,因为要尽量避免Full GC过于频繁。)
虚拟机类加载机制
[图片上传失败...(image-974d3d-1578498765879)]
类的生命周期(7个阶段)
其中加载,验证,准备,初始化,卸载这五个阶段的顺序是确定的。
遇到以下五种情况必须对类进行初始化(而加载、验证、准备自然需要在此之前完成):
- 遇到new、getstatic、putstatic或者invokestatic这四条字节码指令时没初始化触发初始化。使用场景:使用new关键字实例化对象、读取一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)、调用一个类的静态方法。
- 使用java.lang.reflect包的方法对类进行反射调用的时候。
- 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先出法其父类的初始化。
- 当虚拟机启动时,用户需要制定一个要加载的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
- 当使用jdk1.7的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。
对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:”有且只有“,这五种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
类的加载过程
加载
”加载“是”类加载“过程的一个阶段,在加载阶段,虚拟机需要完成以下的三件事情:
- 通过一个类的全限定名来获取定义此类的二进制流(ZIP 包、网络、运算生成、JSP 生成、数据库读取)。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,座位方法区这个类的各种数据的访问入口。
数组类的特殊性
费数组的加载阶段(更准确的说就是加载阶段中获取二进制字节流的动作)是开发者可控性最强的,因为即可以使用系统提供的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。(loadClass()方法)
数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但是数据类与类加载器仍有很密切的关系,因为数组类的元素类型最终是要靠类加载器区创建的,数组创建过程如下:
- 如果数组的组件类型是引用类型,那就递归采用类加载器加载,数组将在加载该组件类型的类加载器的类名称空间上被标识;
- 如果数组的组件类型不是引用类型,Java虚拟机会把数组标记为引导类加载器关联;
- 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public。
内存中实例化一个java.lang.Class对象并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,他虽然是对象,但是存在方法区中。作为程序访问方法区中这些类型数据的外部接口。加载阶段和连接阶段的部分内容是交叉进行的,但是开始时间是保持先后顺序的。
验证(连接的第一步)
验证是连接的第一步,因为入上一步所说,Class文件并不一定要求用Java源码编译而来,可以使用任何途径产生的Class文件,所以需要确保Class文件的字节流中包含的信息符合当前虚拟机的要求。
验证大致分为四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证:
- 是否以魔数 0xCAFEBABE开头
- 主、次版本号是否在当前虚拟机的处理范围之内
- 常量池的常量是否有不被支持的常量类型(检查常量tag标志)
- 指向常量的各种检索值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info型的常量中是和否有不符合UTF-8编码的数据
- Class文件中各个部分集文件本身是否有被删除的附加的其他信息
- ......
这个阶段的验证是基于二进制字节流进行的,通过了这个阶段之后才会进入内存的方法区进行存储,后面3个验证阶段全部是基于方法区的存储结构进行的,不再直接操作字节流。
元数据的验证:
- 这个类是否有父类(除java.lang.Object之外)
- 这个类的父类是否继承了不允许被继承的类(final修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(覆盖了弗雷德final字段、出现了不符合规范的重载)
这一阶段主要是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
字节码验证:
- 保证任何时刻操作数占的数据类型与指令代码序列都能配合工作(不会出现按照long类型读取一个int类型数据)
- 保证跳转指令不会跳转到方法体之外的字节码指令上
- 保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法)
- ......
这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。这个阶段对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。
符号引用验证
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的访问性是否可被当前类访问
- ......
最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证肯以看作是对自身以外(常量池中的各种符号引用)的信息进行匹配性校验,符号引用校验的目的是确保解析动作能够正常执行,否则会抛出异常。
准备
这个阶段正式为类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。
public static int value = 1127;
被static修饰的变量叫做类变量,其他叫做实例变量,会分配在Java堆中。
只是将对应变量设置初始值(例如int型为0)而并不是赋值,因为此时尚未开始执行任何Java方法。而putstatic指令是程序被编译后,存放在clinit()方法中,所以初始化阶段才会对变量进行赋值操作。
特殊情况:如果类字段属性表中存在ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为1127。
解析
这个阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
- 符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。 - 直接引用
直接引用可以使直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不通虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应常量池的7种常量类型。
初始化
前面过程都是以虚拟机主导(除了在加载阶段可以通过自定义类加载器参与),而初始化阶段开始执行类中的Java代码(或者说是字节码)。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则会根据代码去初始化类变量和其他字段。
static {
i = 0; //正常可以赋值
System.out.println(i); //会报错
}
static int i = 1;
<clinit>方法 编译期会自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生,编译期收集的顺序是由语句出现在源文件中的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在之后的变量,可以赋值但是并不能范围。
<clinit>方法与类的构造方法(或者说与实例构造器<init>方法)不同,他不需要显式的去调用父类构造器,虚拟机会保证在子类的<clinit>方法执行前,父类的<linit>方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>方法的类肯定是java.lang.Object。
<clinit>方法对于类或者接口来说并不是必须的,如果一个类中没有静态语句块,也没有变量的赋值操作,那编译期并不会为这个类生成<clinit>方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>方法。但接口与类不同的是,执行<clinit>方法并不需要先执行父接口的<clinit>方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>方法。
类加载器
把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块成为“类加载器”。
类加载器不仅仅可以用于实现类的加载动作,而且可以用来确定这个类在虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
双亲委派机制
从Java虚拟机的角度来讲,只有两种不同的类加载器:
- 启动类加载器,这个类加载器由C++实现,是虚拟机自身的一部分;
- 所有其他的类加载器,由Java实现,独立于虚拟机外部,并且都继承自抽象类java.lang.ClassLoader。
从Java开发的角度,Java程序都会使用到一下三种系统提供的类加载器:
- 启动类加载器。
- 扩展类加载器。
- 应用程序类加载器。
网友评论