JVM相关合集整理

本章主要针对JVM基础知识的整理以及拓展

JVM内存区域

JVM在执行Java程序的过程中会把管理的内存分为若干个不同的数据区域。

JDK1.8前后分区略有不同


JDK 1.8之前JDK1.8之后

根据上述两图,运行时数据区域按照线程是否私有分为两部分:

  • 线程私有:程序计数器、虚拟机栈、本地方法栈
  • 线程共享:堆、方法区

程序计数器

线程私有,当前线程所执行的字节码的行号指示器,记录当前线程执行的位置。

程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到何处。
    • 线程执行Java方法时,计数器记录了当前正在执行的字节码指令地址
    • 线程执行Native方法时,计数器值为Undefined

程序计数器是唯一一个不会出现OutOfMemory的内存区域,它的生命周期随着线程的创建而创建,随线程的结束而死亡。

虚拟机栈

线程私有,描述Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

Java内存可以粗糙的分为堆内存(Heap)栈内存(Stack),栈内存位于虚拟机栈上。

栈内存:存储局部变量方法调用

堆内存:存储Java中的对象(无论成员变量、局部变量、类变量)

如果局部变量是基本数据类型,那局部变量的值存储于栈上;若局部变量是对象,该变量的引用存在于栈上,但是对象存储于堆中。

基本数据类型:boolean、byte、char、short、int、float、long、double


栈帧

虚拟机栈由一个个栈帧组成,栈帧也叫过程活动记录,是用于支持虚拟机调用/执行程序方法的数据结构,记录了每一个方法从调用直至执行完成的过程。栈帧随着方法的调用而创建,执行完成而销毁。

栈帧主要由以下四部分组成:

操作指令-异常指令
局部变量表

用于存储方法参数和定义在方法体的局部变量,包含了编译器可知的各种基本数据类型、对象引用、returnAddress类型。

局部变量表的大小在编译期就已经确定了,对应了字节码中Code属性表中的max_locals

操作数栈

通过入栈、出栈操作来完成一次数据访问,本质是一个临时数据存储区域

是一个后入先出栈(LIFO)

操作数栈的大小在编译期已经确定,对应字节码中的Code属性表中的max_stacks

动态链接

为了支持方法调用过程中的动态连接,调用编译期无法被确定的方法。

在运行期将符号引用转换为所在内存地址的直接引用。

静态链接:被调用的目标方法在编译期可知且运行期保持不变时,那么这种情况下调用方法的符号引用可以转换为直接引用。

返回地址

记录方法被调用的位置,可以在方法执行结束后回到被调用处继续向下执行程序。

当一个方法开始执行后,只有两种方式可以退出这个方法:

  • 正常退出:方法中的代码正常执行完成,或者遇到任意一个方法返回的字节码指令(return)并退出,将返回值传递给上层的方法调用者,没有抛出任何异常。
  • 异常退出:执行方法过程中出现异常,并且没有处理该异常,导致方法退出。

一般方法退出正常值为调用者的PC计数器数值


虚拟机栈会出现两种异常情况:

  • StackOverflowError:请求栈深度超出虚拟机栈说允许的深度时抛出
  • OutOfMemoryError:无法申请到足够的内存时抛出

本地方法栈

线程私有,虚拟机执行Native方法的服务,和虚拟机栈功能类似。

本地方法栈会出现两种异常情况:

  • StackOverflowError:请求栈深度超出虚拟机栈说允许的深度时抛出
  • OutOfMemoryError:无法申请到足够的内存时抛出

Java堆

线程共享

JVM所管理内存中的最大一块,该区域唯一目的是存放对象实例,几乎所有对象实例都在这里分配内存。

因此他也是垃圾收集管理的主要区域,因此也被称作GC堆

由于现在基本都采用分代垃圾回收算法,按照对象存储时间的不同,还可以细分为新生代(分为Eden和Survivor,大致比例为8:1:1)老年代

Java堆结构

Java堆中会出现以下异常情况:

  • OutOfMemoryError:无法申请到足够的内存时抛出

Tips

JVM堆内存溢出后,其他线程是否继续正常工作?

发生OOM之后会不会影响其他线程正常工作需要具体的场景分析。一般情况下,发生OOM的现场都会被终结,然后该线程持有的对象占用就会被GC,释放内存。

方法区(版本区别较大)

线程共享

用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码数据

方法区还有一个别名叫做Non-Heap,可以与上述的Java堆进行区分。

JDK 1.8前

那时方法区也被称为永久代,GC在该区域是比较少出现的,但是不代表不进行GC操作。常见的异常为java.lang.OutOfMemoryError:PermGen space表示了永久代异常信息

JDK 1.8

这时永久代已被移除,代替它的是元空间(meta space)元空间位于直接内存中,因此元空间的最大占用就是系统的内存空间,用户可通过-XX:MetaspaceSize设置元空间最大占用,避免占用过量内存。

Why

  • 由于永久代内存经常会溢出,导致OOM发生,因此JVM开发者希望这块内存可以被更灵活的管理,减少OOM错误的出现。
  • 永久代会为GC带来不必要的复杂度,并且回收效率偏低。
  • 永久代的大小难以确定,太小容易造成方法区发生OOM

方法区会出现两种异常情况:

  • StackOverflowError:请求栈深度超出虚拟机栈说允许的深度时抛出
  • OutOfMemoryError:无法申请到足够的内存时抛出

Tips

如何使方法区发生OOM?

借助CGLib这类字节码技术,不断动态生成新类,新方法。或者使用不同的ClassLoader去加载同一个类(不同的ClassLoader加载的同一个类也是不同的)

  • JDK1.8之前

    可以通过配置-XX:Maxpermsize设置一个较小的值

  • JDK1.8

    上述方法由于移除了永久代无法生效,可以通过配置-XX:MetaspaceSize一个较小的值,也可以模拟这个异常。

常量池

Java中常量池的概念主要有三个:

  • 字符串常量池
  • Class文件常量池
  • 运行时常量池

其中Class文件常量池存在于class文件中,不受JDK版本影响。

字符串常量池在JDK1.6前位于方法区中,之后的版本存在于Java堆

运行时常量池在JDk1.7前位于方法区中,之后的版本存在于元空间

Class文件常量池(Class Constant Pool)

class文件除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是Class文件常量池,用于存放编译器生成的各种字面量和符号引用

字面量

接近Java语言层面的常量

  • 文本字符串

    1
    2
    3
    4
    public String s = "abc";//其中abc为字面量

    对应字节码常量池数据为
    #31 = Utf8 abc
  • 8种基本类型的值

    1
    2
    3
    4
    5
    public int value = 1;

    对应字节码常量池数据为
    #7 = Utf8 value
    #8 = Utf8 I

    常量池只保留了字段描述符(I)和字段名称(value),字面量不存在于常量池中。

  • final修饰的成员变量,包括静态变量、实例变量,局部变量

    1
    2
    3
    4
    public final static int f = 2;//其中2为字面量

    对应字节码常量池数据为
    #11 = Integer 2
符号引用

用一组符号描述所引用的目标,符号可以是任何形式的字面量。

  • 类和接口的全限定名

    1
    2
    3
    4
    5
    public String s = "abc";

    对应字节码常量池数据为
    #5 = Class #10 // java/lang/String
    #10 = Utf8 Ljava/lang/String;

    其中String对应全限定名为java/lang/String存储于常量池中

    主要用于在运行时解析得到类的直接引用

  • 字段的名称和描述符

    字段:类或接口中声明的变量,包括类级别变量和实例级的变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public int value = 1;

    对应字节码常量池数据为
    #4 = Fieldref #5.#32 // JavaBasicKnowledge/JavaBean.value:I
    #5 = Class #33 // JavaBasicKnowledge/JavaBean
    #32 = NameAndType #7:#8 // value:I

    #7 = Utf8 value
    #8 = Utf8 I

    对于方法中的局部变量名,class常量池中仅仅保存字段名

    1
    2
    3
    4
    5
    6
    7
    public void XX(int v){
    int temp = 3;
    }

    对应字节码常量池数据为
    #23 = Utf8 v
    #24 = Utf8 temp
  • 方法的名称和描述符

    保存的是方法名、参数类型+返回值

    1
    2
    3
    4
    5
    6
    7
    public void XX(int v){
    ...
    }

    对应字节码常量池数据为
    #21 = Utf8 XX //方法名
    #22 = Utf8 (I)V //参数类型+返回值

字符串常量池(String Constant Pool)

在JDK1.7及之后版本中,字符串常量池被移动到Java堆中(可能是因为方法区的内存空间太小)。

  • JDK1.7之前

    字符串常量池的位置在方法区,此时存储的是字符串对象

  • JDK1.7及之后

    字符串常量池中的内容是在类加载完成,经过验证、准备阶段之后在Java堆中生成字符串对象实例,然后将该对象实例引用值存在字符串常量池中。字符串常量池中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间进行存放的。

在HotSpot VM里实现的String Pool对应一个StringTable类,实际是一个Hash表,默认值大小长度为1009(如果放入过多,导致Hash冲突使链表变长,导致查询性能大幅下降)。该StringTable在每个VM的实例只有一份,被所有的类共享。

在JDK1.7版本中,StringTable长度可以通过配置参数指定——-XX:StringTableSize=${num}指定长度。

创建字符串对象
1
2
3
4
// 编译期就已经确定该字面量,会直接进入class文件常量池中,在字符串常量池中会保存一个引用
String s0 = "Hello";
// 调用了String的构造函数,创建的字符串对象是在堆内存上
String s1 = new String("Hello");
字面量何时进入常量池
  1. 加载类的时候,那些字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池
  2. 当字面量赋值的时候,会翻译成字节码中的ldc指令,将常量从常量池中推送至栈顶。

运行时常量池

在JDK1.7及之后的版本已将运行时常量池方法区移了出来,在Java堆中开辟一块区域存放运行时常量池。

为了存储class文件常量池中的符号信息,在解析的时候会去查询字符串常量池,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

JVM在执行某个类的时候,必须经过加载、链接(验证,准备,解析)、初始化过程。

当类执行加载过程后,JVM将class常量池中的内容存放到运行时常量池中,已知class文件常量池中存储的内容是字面量与符号引用

准备阶段在Java堆中生成字符串的实例对象,将生成的实例对象引用放置于字符串常量池

解析阶段将class文件常量池中的符号引用翻译成直接引用也是存储于运行时常量池中。

动态性

Java规范并不要求常量只在运行时才能产生,也就是表示运行时常量池的内容不一定都来自于class文件常量池,在运行时可以通过代码生成常量放置于运行时常量池中,例如String.intern()

String.intern()
  • JDK 1.7之前

    intern的处理是:先判断字符串是否存在于字符串常量池中,如果存在直接返回该常量;如果没有找到,则将字符串常量加入到字符串常量池中。

  • JDK 1.7及之后

    intern的处理是:先判断字符串是否存在于字符串常量池中,如果存在直接返回该常量;如果没找到,表示该字符串常量在堆中,然后把Java堆该对象的引用加入到字符串常量池中,以后别人拿到的就是该字符串常量的引用,实际字符串存在于堆中。

直接内存

直接内存并不是JVM的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,可能导致OOM的出现。

在JDK1.4新加入了NIO类,引入一种基于通道(Channel)缓冲区(Buffer)的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过一个存在Java堆的对象作为这块内存的应用进行操作。

Java对象创建过程以及访问方式

Java对象创建过程

在Java语言层面上,创建对象只需要调用new关键字。

在JVM中,实际需要执行以下几步:

类加载检查

遇到一条new指令时,先检查指令对应的参数是否在常量池中可以定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,需要执行相应的类加载过程

分配内存

类加载检查通过后,JVM将为新生对象分配内存,对象所需大小在类加载完成后便可以确定。

这块内存由Java堆划分出来。内存的分配方式由Java堆中内存是否规整决定,而内存是否规整采用的垃圾收集器是否带有压缩整理功能决定

  • 指针碰撞

    Java堆内存规整,把指针向空闲空间挪动对象大小的距离

    对应GC收集器:Serial、ParNew

    关键看GC收集器采用了标记-整理、标记-压缩、复制算法进行回收

  • 空闲列表

    Java堆内存不规整,虚拟机维护一个列表记录内存块中的可用区域,在分配内存的时候,找到一块儿足够大的空间划分给对象实例

    对应GC收集器:CMS

    关键看GC收集器采用了标记-清除算法进行回收

内存分配并发问题

创建对象是一个很频繁的事情,就会涉及一个很重要的问题——线程安全。作为虚拟机来讲,必须要保证线程安全,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试

    CAS是乐观锁的一种实现方式

    乐观锁:假设没有冲突而去完成某项操作,若发生冲突就重试直到成功为止。

    采用这种方式可以保证更新操作的原子性。

  • TLAB(本地线程分配缓存)

    每个线程预先在Java堆中分配一块内存,JVM在给对象分配内存时,首先在TLAB分配。如果分配的对象大于TLAB的剩余内存或TLAB内存已用尽时,再采用上述CAS方式进行内存分配。

初始化零值

内存分配完成时,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头)。这一步操作可以保证对象的实例字段在代码中可以不赋值就直接使用,程序也可以访问到这些字段的数据类型所对应的零值。

设置对象头

初始化零值完成后,虚拟机要对对象进行必要的设置。将类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,存放到对象头中。另外根据虚拟机运行状态的不同,如是否启用偏向锁等,对象头都会进行存储。

可以在对象内存布局这节看到对象头相关内容。

执行<init>方法

从虚拟机角度来说,一个新的对象已经产生了。从代码角度来说,对象才刚开始创建,在执行<init>方法之前,所有的字段都还为零。一般执行完new指令后会接着执行<init>方法,把对象按照意愿进行初始化,这时就产生了一个真正可用的对象。

Jvm对象创建过程

对象内存布局

对象内存布局分为以下三块区域:

对象头(Header)

必须先了解 HotSpot虚拟机的对象(对象头部分)的内存布局:分为两部分

Mark Word

存储自身的运行时数据,如:HashCode、GC分代年龄和锁信息,这部分数据的长度在32和64位中的JVM中分别为32bit和64bit。它是实现轻量级锁和偏向锁的关键。

Mark Word
类型指针

存储指向方法区对象类型数据的指针,如果是数组对象的话,额外会存储数据的长度。JVM通过这个指针来确定该对象是哪个类的实例。

实例数据(Instance Data)

对象真正存储的有效信息,即在代码里面所定义的各种类型的字段内容。

对齐填充(Padding)

并非必然存在的,也没有特别的含义,仅仅起着占位符的作用。

Java对象访问方式

Java程序通过栈上的refrence数据来操作堆上的具体对象。

句柄访问

Java堆可能会划分一块内存作为句柄池,refrence存储的就是对象的句柄地址,句柄中包含了对象的实例数据与类型数据的各自具体地址信息。

alt

refrence中存储的稳定句柄地址,在对象被移动时(例如GC时)只会改变句柄中的实例数据指针,refrence本身不需要修改。

直接访问

Java堆中对象的内存布局就必须考虑如何设置访问类型数据的相关信息,refrence直接存储的就是对象地址

alt

最大好处就是速度快,节省了一次指针定位的时间开销。在HotSpot虚拟机中很常用。

类加载机制

JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。

类的生命周期

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中准备、解析、初始化称为连接

类的生命周期

类的卸载

由JVM自带的类加载器(BootstrapClassLoader根加载器、ExtensionClassLoader拓展加载器、ApplicationClassLoader应用加载器)所加载的类,在虚拟机的生命周期中,都不会被卸载。

只有由用户自定义的类加载器所加载的类是可以被卸载的。

类卸载的触发条件:

  • 该类所有的实例都已被GC,在JVM中不存在任何该类的实例
  • 加载该类的ClassLoader也被GC
  • 该类的Class对象没有被任何地方调用,反射也无法访问该类

执行类卸载后,在方法区的二进制数据会被卸载。

类加载过程

类加载过程包括上述的五步:加载、验证、准备、解析、初始化

加载

JVM找到class文件问生成字节流,然后根据字节流创建java.lang.class对象的过程。

JVM在此过程需要完成三件事:

  • 通过一个类的 全限定名(包名+类名)来查找.class文件,并生成二进制字节流(使用ClassLoader进行加载)。其中字节码来源不一定是.class文件,也可以是jar包、zip包,甚至是来源于网络的字节流。
  • 将字节流所代表的静态存储结构转化为JVM的特定的数据结构,并存储在方法区
  • 在内存中创建一个java.lang.Class类型的对象,作为方法区这个类的各种数据的访问入口。

一个非数组类的加载阶段(加载阶段获取二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写类加载器的findClass()loadClass())。

对于数组类而言,数组类本身不通过类加载器创建,由JVM直接在内存中动态创建。

加载时机

一个项目经过编译之后,往往会产生大量.class文件,程序运行时,JVM不会一次性将这些文件全部加载到内存中,而是有一定的加载时机去进行加载操作。

隐式装载

在程序运行过程中,当碰到通过new生成对象时,系统会隐式调用ClassLoader装载对应class到内存中(loadClass())

1
protected Class<?> loadClass(String name, boolean resolve)
显示装载

在编写源代码时,主动调用Class.forName()也会进行class装载操作。执行时会默认调用静态代码块static{...}以及分配静态变量存储空间

1
2
3
public static Class<?> forName(String name, /*要加载的Class名字*/
2222222222 boolean initialize,/*默认为true,是否需要初始化-调用静态代码快及静态变量初始化*/
ClassLoader loader/*指定ClassLoader进行加载*/)

验证

确保.class文件的字节流中包含的信息符合虚拟机规范的全部要求,并且不会危及虚拟机本身的安全。

若代码被反复验证和使用过,可以通过配置-XVerify:none关闭大部分的验证措施,缩短加载时间

主要包含以下四个方面的验证:

文件格式验证

验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。

这一阶段可能包含以下验证点:

  • 是否以魔数0xCAFEBABE开头
  • 主次Java版本号是否在当前JVM接受范围内
元数据验证

对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java语言规范的要求

这一阶段可能包含以下验证点:

  • 这个类是否有父类(除了 java.lang.Object外,都应该有父类)
  • 这个类是否继承了不允许被继承的类(被final修饰的类)
字节码验证

通过数据流分析和控制流分析,确定程序语义是合法、符合逻辑的

这一阶段可能包含以下验证点:

  • 任意时刻操作数栈的数据类型与指令代码序列都配合工作
  • 任何跳转指令都不会跳到方法体以外的的字节码指令中
符号引用验证

发生于JVM将符号引用转换直接引用的时候。

对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验。通俗来说就是,该类是否缺少或者被禁止访问她依赖的某些外部类、方法、字段等资源。

这一阶段可能包含以下验证点:

  • 符号引中通过字符串描述的全限定名能否找到对应的类
  • 在指定类是否存在符合方法的字段描述符及简单名称所描述的方法和字段

准备

正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置为变量初始值(零值)的阶段,不包括实例变量。

1
public static int value = 100;

在准备阶段,JVM会为value分配内存,并将其设置为0。真正的100需要在初始化阶段进行设置。

数据类型 零值
Int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference Null

以上是通常情况下初始值是零值,还是会存在一些特殊情况——静态常量。

1
public static final int value = 100;

此时value的初始值就为100。

解析

把常量池中的符号引用转换为直接引用,也就是具体的内存地址。JVM会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。

符号引用

以一组符号描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。

直接引用

直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。对象真正的内存地址

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。

初始化

类加载的最后一个步骤,直到这一步,JVM才真正开始执行类中编写的Java代码。

执行类构造器()的过程,并真正初始化类变量(前面准备过程的零值在此时被赋予真正的值)

初始化执行时机

JVM规范严格规定类class初始化的时机,主要有以下几种情况:

  • 虚拟机启动时,初始化包含main()的主类

  • 遇到new(创建对象实例)、getstatic(读取类静态字段)、putstatic(设置类静态字段)、invokestatic(调用类的静态方法)这四条字节码指令时,如果目标对象没有经过初始化,需要执行初始化操作

  • 当需要对类进行反射调用时,如果类型没有进行初始化,需要执行初始化操作

  • 当初始化子类的时候,发现父类还没有进行初始化,需要执行父类的初始化操作

  • 在第一次调用java.lang.invoke.MethodHandle实例时,需要初始化MethodHandle指向方法所在的类。JDK7之后

  • 当一个接口中定义了JDK8新加入的默认方法(default关键字修饰),如果实现了这个接口的类进行初始化,那么接口需要执行初始化操作

    1
    2
    3
    4
    5
    6
    7
    8
    public interface DefaultInterface {
    //默认接口方法
    default void test(){
    System.err.println("Default Interface Method");
    }

    void test1();
    }

以上6种情况在JVM中被称为主动引用,除此之外的其他应用方式都被称为被动引用,不会出发Class的初始化操作。

例如以下几种情况:

  • 通过子类调用父类的静态变量,不会导致子类初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class Parent{
    public static int value = 1;
    static {
    System.out.println("Parent");
    }
    }

    public class Child extends Parent{
    static {
    System.out.println("Child");
    }
    }

    public class Test{
    public static void main(String[] args){
    Child.value = 2;
    }
    }

    日志输出
    java NonInitTest
    Parent

    只有直接定义这个字段的类才会被初始化,所以子类不会进行初始化。

  • 静态常量引用时,不会触发定义常量类的初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class ConstClass{
    static {
    System.out.println("Const");
    }

    public static final String value ="Value";
    }

    public class Test{
    public static void main(String[] args){
    System.out.println(ConstClass.value);
    }
    }

    日志输出
    Value

    常量实际在编译阶段直接存储在Test类的常量池中,已于ConstClass无关,所以不会导致初始化。

Class初始化和对象的创建顺序

在代码中使用new创建对象实例时,类中静态代码块、非静态代码块、构造函数之间的执行顺序是如何的?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Parent.java

public class Parent {
public static String value = "Parent";

static {
System.err.println("Parent Static Block");
}

{
System.err.println("Parent non-static Block");
}

public Parent(){
System.err.println("Parent Constructor");
}
}


Child.java

public class Child extends Parent{
static {
System.err.println("Child Static Block");
}

{
System.err.println("Child Non-Static Block");
}

public Child(){
System.err.println("Child Constructor");
}
}

Test.java

public class Test {
public static void main(String[] args) {
Parent p =new Child();
System.err.println("~~~~~~~~~~");
p = new Child();
}
}

输出内容为

1
2
3
4
5
6
7
8
9
10
11
Parent Static Block
Child Static Block
Parent non-static Block
Parent Constructor
Child Non-Static Block
Child Constructor
~~~~~~~~~~
Parent non-static Block
Parent Constructor
Child Non-Static Block
Child Constructor

按照上述输出内容,可以总结初始化顺序为:

1
2
3
4
5
6
1.父类静态变量和静态代码块
2.子类静态变量和静态代码块
3.父类普通成员变量和普通代码块
4.父类的构造函数
5.子类普通成员变量和普通代码块
6.子类的构造函数

基础规则:静态变量/静态代码块 -> 普通变量/普通代码块 -> 构造函数

特殊情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class StaticTest {
public static void main(String[] args) {
staticFun();
}
//特殊之处
static StaticTest st = new StaticTest();

static {
System.err.println("1");
}

{
System.err.println("2");
}

StaticTest() {
System.err.println("3");
System.err.println("a" + a + " b" + b);
}

public static void staticFun() {
System.err.println("4");
}

int a = 100;
static int b = 100;
}

TODO:需要好好分析流程。

类加载器

在Java程序启动的时候,并不会一次性加载程序中所有的.class文件,而是在程序运行的过程中,动态加载相应的类到内存中。

同一个类使用不同的类加载器,得到的类也是不一样的。

Java类加载器

  • 启动类加载器(BootstrapClassLoader)

    由C/C++语言编写的,本身属于虚拟机的一部分,无法在Java代码获取他的引用。可以以null代表引导类加载器。

    负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定路径的类库

  • 拓展类加载器(ExtensionClassLoader)/PlatformClassLoader(JDK9后改名)

    由Java语言编写,可以直接在程序中使用

    负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs所指定的路径中的所有类库

  • 应用程序/系统类加载器(ApplicationClassLoader)

    负责加载用户类路径(java.class.path)上所有的类库,我们自己编写的代码以及使用的第三方jar通常由他进行加载。

    若没有自定义类加载器,默认由他进行类加载。

  • 自定义类加载器(CustomClassLoader)

    上述三种加载器只能加载特定目录下的class文件,如果需要加载特殊位置下的jar包或类时(磁盘上的class),就需要继承java.lang.ClassLoader去实现功能。

    自定义ClassLoader步骤如下:

    1. 自定义一个类继承ClassLoader
    2. 重写findClass()
    3. findClass()中,调用defineClass()将字节码转换成Class对象并返回

    伪代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    Class CustomClassLoader extends ClassLoader{
    @Override
    Class findClass(String name){
    //获得字节码
    byte[] code = loadClassData(name);
    //根据字节码获得Class对象
    return defineClass(name,code);
    }

    //这里是获得Class的字节码数组
    byte[] loadClassData(String name){

    }
    }

双亲委托模型

alt

当类加载器收到类加载请求时,通常都是先委托给父类加载器进行加载,因此所有的类加载请求最终都会传送到最顶层的启动类加载器中,只有当父加载器无法完成这个加载请求时,子加载器才会去进行类加载过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private final ClassLoader parent; 
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查请求的类是否已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理
c = parent.loadClass(name, false);
} else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//抛出异常说明父类加载器无法完成加载请求
}

if (c == null) {
long t1 = System.nanoTime();
//自己尝试加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

按照源码分析,双亲委托模型总共4步:

  1. 判断class是否被加载,已加载直接返回class
  2. class未被加载且parent(父加载器)不为空,父加载器进行加载class
  3. parent(父加载器)为空,直接调用BootstrapClassLoader加载class
  4. 如果parentBootstrap都未加载成功,则调用当前classLoader继续尝试加载class
双亲委托模型好处
  • 避免类的重复加载,若class已被加载直接从缓存读取
  • 保证类加载的安全,避免核心API被篡改,无论哪一个类加载去加载核心类(例java.lang.Object),最终都会由BootstrapClassLoader进行加载。
破坏模型

双亲委托机制只是Java推荐的机制,并不是强制的机制,可以通过一些手段破坏该模型

可以通过继承java.lang.ClassLoader实现自己的类加载器

  • 保持双亲委托模型,只要重写findClass()
  • 破坏双亲委托模型,需要重写loadClass()

Android类加载器

本质上,Android和传统的JVM是一样,也要通过ClassLoader加载目标类到内存,但是加载细节略有差别。

基本运行方式:传入dex文件,然后进行优化,保存优化后的dex文件(odex)到optimizedDirectory目录

Android无法直接运行.class文件,会将所有的.class文件转换.dex文件,Android通过自定义的BaseDexClassLoader加载dex文件,也会通过继承BaseDexClassLoader实现特定功能的子类。

BaseDexClassLoader
1
2
3
4
5
6
public class BaseDexClassLoader extends ClassLoader {
public BaseDexClassLoader(String dexPath, File optimizedDirectory, String librarySearchPath, ClassLoader parent) {
throw new RuntimeException("Stub!");
}
...
}
  • dexPath

    包含目标类或资源的apk,dex,jar文件的路径,也可以是SD卡的路径,存在多个路径时使用;分割

  • optimizedDirectory

    优化后dex文件(odex)存在的目录,可以为null,Android8.0之后,该参数被废弃

  • librarySearchPath

    存放目标文件使用的native库,存在多个路径使用;分割

  • parent

    父加载器

PathClassLoader

加载Android系统类和应用程序的类,在Dalvik只能加载已安装的apk的dex文件(/data/app),在ART没有这个限制。

支持加载外部的dex/apk文件

1
2
3
4
5
6
7
8
9
10
11
public class PathClassLoader extends BaseDexClassLoader {
public PathClassLoader(String dexPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
...
}

public PathClassLoader(String dexPath, String libraryPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
...
}
}

PathClassLoader传入的optimizedDictory为空,表示只能加载系统默认位置(/data/dalvik-cache/)的odex文件。

DexClassLoader

支持加载外部的dex/apk文件,但是可以配置optimizedDirectory指定odex存放位置。

1
2
3
4
5
6
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent) {
super((String)null, (File)null, (String)null, (ClassLoader)null);
...
}
}

可以支持BaseDexClassLoader配置的所有参数。

Android类加载过程

Android-Art类加载过程

JVM垃圾回收机制(GC)

GC:自动管理回收不再引用的内存数据

JVM内存运行时区域分为5部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程生而生,随线程灭而灭,是不需要考虑内存回收的问题,内存自然会回收。Java堆和方法区不一样,只有在程序运行期间才知道创建哪些对象,这部分内存的分配和回收是动态的,主要在这两部分触发GC。

对象是否已死(什么是垃圾)

堆中几乎存放所有的对象实例,垃圾回收(GC)前的第一步是判断哪些对象已经死亡(不再被任何途径引用的对象)。

引用计数法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器加1;引用失效,计数器减1;任何时候计数器为0的对象就是不可能在被使用的。

引用计数法虽然需要占用额外的内存空间来进行计数,但是原理简单,效率也高

但是主流的Java虚拟机里面都没有使用该方法,主要原因是必须配合大量额外处理才能保证正确的工作,例如无法解决对象之间相互循环引用的问题

可达性分析算法

通过一系列称为GC Roots的根对象作为起始点,从这些节点开始搜索,搜索过程走过的路径称为引用链,如果一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。

可达性分析算法

可以作为GC Roots的对象类型:

  • 虚拟机栈中的引用对象(局部变量表)
  • 方法区中静态属性引用的对象(字符串常量池中的引用),常量引用的对象
  • 本地方法栈中JNI所引用的对象

什么时候回收

一般会在以下两种情况下触发GC

  1. Allocation Failure:如果内存剩余可用空间不足导致对象内存分配失败,系统会触发一次GC
  2. System.gc():开发者可主动调用该API触发一次GC

四大引用类型(Java堆)

引用类型 GC时机 用途
强引用 不会被回收 对象一般状态
软引用 内存不足时(即将OOM时) 内存敏感的高速缓存
弱引用 触发GC时 对象缓存
虚引用

强引用Strong Reference

在程序代码间普遍存在的引用赋值。无论何种情况,只要存在强引用关系,就永远不会被垃圾回收器回收。即使发生OOM。

强引用也是造成Java内存泄露的原因之一。

对于一个普通的对象,如果没有其他的引用关系,若显式的将对象赋值为null,就可以认为该对象可以被回收。

设置对象为null,不代表对象会被立即回收,具体回收时机需要看垃圾收集策略。

1
2
3
4
5
6
7
8
public static void main(String[] args){
Object o1 = new Object();
Object o2 = o1;
o1= null;
System.gc();
System.out.println(o1); //null
System.out.println(o2); //java.lang.Object@XX
}

软引用Soft Reference

描述一些还有用,但并未必需的对象。对于软引用关联的对象,在系统即将发生OOM之前,会把这些对象进行GC,如果GC完毕还没有充足空间,就抛出OOM异常。

实现内存敏感的高速缓存。

只有系统内存不足时才会被回收,其他情况下等效强引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args){
Object o1 = new Object();
SoftReference<Object> s1 = new SoftReference<Object>(o1);
System.out.println(o1);
System.out.println(s1.get());

o1 = null;
System.gc();

System.out.println(o1); // null
System.out.println(s1.get()); //java.lang.Object@XX
}

//JVM配置`-Xms5m -Xmx5m`
//试图new一个大对象,使内存不足产生OOM,看软引用回收情况
...
byte[] bytes = new byte[10*1024*1024];
...

此时会去试图回收软引用对象。

弱引用Weak Reference

弱引用也是描述非必须对象,但强度比软引用更弱一些,被弱引用关联的对象只能生存在下一次GC前。

无论内存是否足够,弱引用关联的对象都会被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
Object o1 = new Object();
WeakReference<Object> w1 = new WeakReference<Object>(o1);

System.out.println(o1);
System.out.println(w1.get());

o1 = null;
System.gc();

System.out.println(o1); //null
System.out.println(w1.get()); //null
}

WeakHashMap就是弱引用的一个使用实例

其中key为弱引用类型,当key不在引用时,对应的key/value也会被移除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
WeakHashMap<String,String> weakHashMap = new WeakHashMap<>();
String key = new String("111");
//String key ="111";

String value= "value";

weakHashMap.put(key,value);
System.err.println(weakHashMap);// {111=value}

key = null;
System.gc();
System.err.println(key); //null
System.err.println(weakHashMap); //{}
}

当使用String key = “111”时,本质引用的对象已经变成字符串常量池中的对象,这部分的回收无法被GC处理。也导致了weakHashMap对象不为空。

虚引用Phantom Reference

最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对生存时间构成影响,也无法通过虚引用来取得一个对象实例。

主要用于跟踪对象垃圾回收的状态,在这个对象被回收时可以收到一个系统通知或者后续添加进一步的处理。

虚引用必须与引用队列联合使用,当准备回收一个对象时,发现对象存在虚引用,就会在回收对象之前把虚引用加入关联的引用队列中,可以根据引用队列是否已加入虚引用来判断被引用的对象是否要被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws InterruptedException {
Object o1 = new Object();
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<Object>();
PhantomReference<Object> phantomReference = new PhantomReference<Object>(o1,referenceQueue);

System.out.println(o1); //java.lang.Object@xxx
System.out.println(referenceQueue.poll()); //null
System.out.println(phantomReference.get()); //null

o1 = null;
System.gc();
Thread.sleep(3000);

System.out.println(o1); //null
System.out.println(referenceQueue.poll()); //引用队列中 java.lang.PhantomReference@xxx
System.out.println(phantomReference.get());//null
}

引用队列Reference Queue

配合引用工作的,当GC准备回收一个对象时,如果发现对象被软引用或弱引用或虚引用包装,就会在回收对象前将引用加入到引用队列中。

如果一个引用(软引用、弱引用、虚引用)存在引用队列中,则表示该引用指向的对象已被回收。

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
String s = new String("11");
WeakReference<String> weakReference = new WeakReference<String>(s,referenceQueue);
s= null;
System.gc();
System.err.println(s); //null
//被回收了对象
System.err.println(weakReference.isEnqueued()); //true
System.err.println(referenceQueue.poll()); //java.lang.ref.WeakReference@5e481248
}

ReferenceQueue是一个先进先出的队列。

引用 Reference

上面的弱引用、软引用、虚引用都是java.lang.ref.Reference的直接子类。

Reference主要存在四种状态

  • Active

    新创建的实例为Active状态

  • Pending

    当实例等待进入引用队列时,处于Pending状态。未注册引用队列时永远不会处于此状态

  • Enqueued

    当实例进入引用队列时,处于Enqueued状态。未注册引用队列时永远不会处于此状态

  • Inactive

    该引用实例指向的实际对象一定已被回收。引用实例未注册引用队列直接从Active状态进入到Inactive状态。

使用实例

例如Leakcanary,内部主要原理就是:弱引用+引用队列

在一个Activity执行完onDestroy()后,用WeakReference引用Activity,再将引用对象与ReferenceQueue关联。这时再从ReferenceQueue中查看是否存在该弱引用对象

如果存在,执行一次手动GC,再次移除引用,如果弱引用不存在,则这次执行结束。

如果不存在,执行一次手动GC,再次查看是否存在弱引用对象,如果不存在则表示已发生内存泄露。

回收方法区

方法区的GC性价比比较低,方法区的回收条件比较苛刻,比较少用。

方法区的垃圾收集主要回收两部分内容:

废弃常量

如果常量池中存在字符串wxy,但是当前没有任何String对象引用该字符串常量。就表示了当前这个常量处于废弃状态,当发生内存回收的时候而且有必要进行方法区回收,就会清理wxy出常量池。

无用类

需要同时满足以下三个条件:

  • 该类所有的实例都已被回收,Java堆中已不存在该类的任何实例
  • 加载该类的类加载器已被回收,所以必须是自定义加载器去加载
  • 该类对应的java.lang.Class对象没有在任何地方被引用,也无法在任何地方被反射访问。

GC可以对满足上述三个条件的无用类进行回收,但不是必然会进行回收。

垃圾收集算法

标记-清除算法

最基础的收集算法

算法分为两个阶段:

  • 标记(Mark)

    标记所有需要回收的对象。找到内存中所有的GC Root对象,然后找到与上述对象没有关联的对象(需要回收的垃圾)。

  • 清除(Sweep)

    回收掉所有被标记的对象

标记-清除算法

优点:

  • 实现简单,不需要移动对象

缺点:

  • 执行效率不稳定。如果存在大部分需要回收的对象,导致标记、清除两个动作执行效率降低。
  • 内存碎片问题。清除过程后会产生大量不连续的内存碎片,导致下次分配大对象时没有连续内存导致再次触发GC。

复制算法

将可用内存分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,将剩下的对象复制到另一块内存上。然后再清理已使用过的另一块内存,完成GC。

整理算法

优点:

  • 按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片

缺点:

  • 可用内存减少一半。对象存活率较高时会频繁进行复制。

标记-整理算法

标记-清除算法的升级版

算法分为两个阶段:

  • 标记(Mark)

    标记所有需要回收的对象。找到内存中所有的GC Root对象,然后找到与上述对象没有关联的对象(需要回收的垃圾)。

  • 整理(Compact)

    移动剩余存活对象到内存的某一端。然后直接清理边界外的内存对象。

标记-整理算法

优点:

  • 避免内存碎片的产生 相比于标记-清除算法
  • 高效利用内存空间 相比于复制算法

缺点:

  • 移动对象的过程必须全程暂停用户应用程序(STW-Stop The World),降低了效率。

*分代收集理论

主流JVM使用的垃圾收集算法

根据对象存活的周期不同,把堆内存划分几块,一般分为新生代老年代。根据不同年代的特点使用不同的垃圾收集算法。

对于新创建的对象会在新生代分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将他们转移到老年代中。

新生代

新生成的对象优先存放新生代,新生代对象朝生夕死,存活率很低,所以回收效率很高。

新生代经常要进行一些复制操作,所以一般采用复制算法进行回收。

新生代继续分为3部分:Eden、From Survivor、To Survivor。这三部分并非均分,而是按照8:1:1的比例进行划分。

新生代GC过程如下:

  1. 绝大多数新创建对象都会先存放在Eden
  2. Eden区满时,会执行一次GC(Minor GC),清除Eden区的垃圾对象,将存活的对象复制到From Survivor
  3. From Survivor区满时,会执行一次Minor GC,将存活的对象复制到To Survivor区。如果存在可以晋升的对象会直接放到老年代中。
  4. From SurvivorTo Survivor区域进行切换。每次切换过程中即GC过后,对象的年龄+1,直到达到晋升年龄阈值(一般为15)之后,对象被放到老年代。——长期存活的对象直接进入老年代

晋升年龄阈值:该值的大小影响着对象在新生代中的停留时间,可以通过-XX:MaxTenuringThreshold配置数值。

老年代

在新生代经历了N次(晋升年龄阈值)回收之后仍然存活的对象,就会放入老年代

老年代的内存一般比新生代大(大概比例为2:1),可以存放更多的对象。

如果对象比较大(升入老年代对象大小),并且新生代无法存放,则这个大对象会被直接分配老年代上。——大对象直接进入老年代

老年代通常使用标记-清除、标记-整理算法进行GC。

升入老年代对象大小:如果新生代的对象需要分配一块较大连续内存空间才可以存放,且该大小大于该值,则直接在老年代进行内存分配,可以通过-XX:PretenureSizeThreshold配置数值。

老年代发生的GC称为Major GC,针对老年代的GC操作,通常伴随一次Minor GC

在某些虚拟机中,还有Full GC,针对新生代与老年代的GC,回收整个堆的内存。发生时,会导致长时间停顿。


老年代有时候会引用新生代对象,当执行Minor GC时可能就需要查询老年代的引用情况。导致GC过程低效。

所以老年代会维护一个Card table,记录老年代所引用的新生代对象信息,在发生Minor GC时,只要检查Card table即可。

Java堆内存

垃圾收集器

垃圾收集器

Serial收集器

最基本,发展历史最悠久的收集器。

是一个单线程工作的收集器,只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是进行垃圾回收时,必须停止其他所有工作线程(Stop The World),直到收集结束。

新生代采用复制算法,老年代采用标记-整理算法

优点:

  • 简单而高效
  • 没有线程交互的开销,可以获得最高的单线程收集效率

缺点:

  • Stop The World可能时间过长

ParNew收集器

其实就是Serial收集器的多线程版本,不同的就是使用多线程进行垃圾收集

新生代采用复制算法,老年代采用标记-整理算法

除了Serial收集器以外,只有它可以和CMS收集器配合工作。

默认开启的收集线程数与CPU数一致。

*CMS收集器

获取最短回收停顿时间为目标的收集器。

基于标记-清除算法实现。整体上来说是内存回收线程用户线程并发执行。

应用于老年代的垃圾收集器。

运作过程比较复杂,分为以下4步:

  1. 初始标记

    暂停所有其他线程,并记录下与GC Roots关联的对象。触发Stop-The-World

  2. 并发标记

    从GC Roots直接关联对象开始遍历整个对象图的过程(GC Roots Tracing)。这个过程耗时较长但不用停顿用户线程,主要跟踪记录发生引用更新的地方。

  3. 重新标记

    为了修正并发标记期间,因用户线程继续运行而导致标记产生变动的对象的标记记录。触发Stop-The-World

  4. 并发清除

    清理删除掉在标记阶段判断的垃圾对象,可以与用户线程一起工作。

CMS

优点:

  • 并发收集
  • 低停顿

缺点:

  • 对CPU资源非常敏感
  • 无法处理浮动垃圾
  • 产生大量内存碎片(由于标记-清除算法的实现)

*G1收集器

主要面向服务器的垃圾收集器,以极高概率满足GC、停顿时间要求的同时,还具备高吞吐量性能特征。

具备如下特点:

  • 并行与并发

    使用多个CPU缩短STW时间,还可以通过并发的方式让Java程序继续运行。

  • 分代收集

    分代概念在G1收集器中进行了保留,但G1可以直接管理新生代和老年代,然后采用不同的方式去管理。

  • 空间整合

    整体基于标记-整理算法,局部(两个Region之间)采用了复制算法

  • 可预测的停顿

    除了追求低停顿外,还可以建立可预测的时间模型,用户去指定期望停顿时间。

运作过程分为以下4步:

  1. 初始标记

    标记一下与GC Roots直接关联的对象。需要停顿线程

  2. 并发标记

    从GC Roots对象开始对堆中对象进行可达性分析,找出需要回收的对象。

  3. 最终标记

    修正并发标记期间因为用户线程继续运行导致标记发生变动的对象。需要停顿线程

  4. 筛选回收

    对各个Region的回收价值和成本进行排序,根据用户指定期望停顿时间制定回收计划。然后把决定回收的那部分Region存活对象复制到空Region中,再清理旧Region空间。

    必须暂停用户线程,因为涉及到对象的移动。

Region

Java堆的内存布局被划分为多个大小相等的区域(Region),虽然保留了分代概念,但新生代老年代都变成了Region的集合。

G1收集器认为大小超过Region容量一半的对象判定为大对象,存放于Humongous区域。

可停顿的时间模型

在后台维护了一个优先列表,每次根据用户设置的期望停顿时间,优先选择回收价值(回收获得的空间大小以及回收所需时间的经验值)最大的Region。

记忆集(Remembered Set)

每个Region都会存在一个记忆集,里面记录下别的Region指向自己的指针并标记这些指针分别在哪些页卡的范围之内。

通常约占Heap大小的20%或者更高。

ZGC收集器

在JDK 11中加入的低延迟垃圾收集器。

主要新增了两项新技术

  • 着色指针

    将少量额外的信息存储在指针上,在对象的内存发生变化的时候,指针颜色就会发生变化。就能知道当前对象状态

  • 读屏障

    由于着色指针的存在,程序访问对象的时候可以轻易知道对象的存储状态,若发现指针变色,则会触发读屏障,会更新指针并重新返回结果,虽然存在一定的耗费但是可以达到与用户线程并发的效果。

与标记对象的传统算法相比。ZGC在指针上做标记,并在访问指针时加入读屏障,比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就先把指针更新为有效地址再返回,永远只会有单个对象读取时有概率被减速(需要更新指针地址),而不会再发生Stop-The-World。

JVM内存分配策略

  • 对象优先在Eden区分配

    大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC

    每次GC后,对象依然存在就会进入两块Survivor区内

  • 大对象直接进入老年代

    大对象:那种很长的字符串或者元素数量很多的数据。需要连续内存空间的Java对象。

  • 长期存活的对象直接进入老年代

    每经过一次Minor GC仍然存活的对象,并且能被Survivor容纳,其对象年龄就会+1,当达到晋升年龄阈值对象就会晋升到老年代

    晋升年龄阈值:默认为15,通过-XX:MaxTenuringThreshold进行配置。

  • 动态对象年龄判定

    为了更好适应不同的内存情况,不一定对象达到年龄阈值才能晋升老年代。

    如果在survivor区相同年龄的对象大小总和超过Survivor空间的一半,所有年龄大于或等于该年龄的对象都可以直接晋升老年代。

  • 空间分配担保

    如果survivor没有足够空间存放在Eden区存活对象,这些对象将通过分配担保机制直接进入老年代。

Java内存模型(JMM)

CPU缓存一致性

缓存一致性问题

线程是CPU调度的最小单位。

由于CPU的发展,执行速度越来越快,内存与CPU的执行差距会越来越大,导致数据的交互需要等待较长时间。

因此,为了提升CPU的使用效率,在CPU中添加了高速缓存(cache)作为内存与CPU之间的缓冲:将运算需要的数据复制到Cache中,让运算能快速进行,当运算完成之后,将运算结果刷回主内存,这样CPU就无需等待内存读写完毕。

由于每个CPU都有自己的cache,当多个CPU共同操作一块主内存时,可能导致各自cache中的数据不一致,发生缓存一致性问题。

为了解决缓存一致性的问题,需要各个处理器访问缓存时遵循一些协议,在读写时要根据协议来进行操作,这类协议有MESIMSIMOSI等。

MESI(缓存一致性协议)

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态。
因此当其他CPU需要读取这个变量时,发现自己缓存变量的缓存行无效时,就需要从主内存中重新获取变量值。

上面介绍到每个处理器都会去检测自己的缓存变量是否有效?,这个检测机制就是通过嗅探来实现的。

嗅探:每个处理器都会去检测主内存上的数据来判断自己的缓存数据是否有效,当CPU发现缓存数据对应的内存地址发生修改时,就意味着缓存数据已经无效,需要做以下几步:

  • 将当前CPU的缓存行设置无效
  • 当CPU需要操作该数据时,就需要重新从主内存中读取数据
  • 读取完毕后更新自己的缓存对象

嗅探需要持续的从主内存检测数据并且通过CAS进行循环获取,导致占用的总线带宽较高。这也被称之为总线风暴

指令重排

为了使CPU的运算单元能够尽量被充分利用,CPU会对输入的代码进行重排序处理,也就是处理器优化

as-if-serial

不管怎么重排序,都不允许单线程下的程序执行结果发生改变。

编译器、runtime和CPU都必须遵守as-if-serial协议。

一般重排序分为以下三种:

  • 编译器优化的重排序
  • 指令级并行的重排序
  • 内存系统的重排序

概念

描述了Java程序中各种变量(线程共享变量)的访问规则,屏蔽了各种硬件和操作系统的内存访问差异,实现让Java程序在各种平台都能达到一致的内存访问效果。

主要目的

定义程序中各种变量的访问规则,关注的是虚拟机中把变量值存储到内存中和从内存中取出变量值这样的底层细节

此处的变量指的是实例字段、静态字段和构成诉诸对象的元素,不包括局部变量

主内存与工作内存

线程-主内存-工作内存的交互关系

主内存:所有的变量都存储于此

工作内存:每条线程都存在自己的工作内存,保存了该线程使用变量的主内存副本。线程对变量所有的操作都必须在工作内存中进行,无法直接操作主内存数据。不同线程之间也不能互相访问工作内存中的变量,线程间传值都需要通过主内存中转完成。

原子性、可见性和有序性

JMM模型具有以下特征:原子性、可见性、有序性

原子性

对基本数据类型的变量读取和赋值操作都是原子性操作,这些操作不可被中断,要么执行,要么不执行。

可以通过synchronizedLock实现原子性,因为两者能够保证同一时刻只有一个线程访问该代码块

原子性操作包括:

  • longdouble之外的基本数据类型赋值和读取操作,如果需要保证原子性需要加上volatile关键字修饰
  • 所有引用refrence的赋值操作
  • java.util.concurrent.atomic.*包下的操作,例如AtomicInteger

可见性

当一个线程修改了共享变量的值,其他线程能够立即得知这个修改,修改后的值立即更新到主内存中,在其他线程读取时,会重新从主内存获取值

volatile可以保证可见性,具体参考volatile

synchronizedLock也可以保证可见性,可以保证同一时刻只有一个线程访问共享资源,并在其释放锁之前将修改变量更新到主内存中

final也可以实现可见性,对象一旦初始化完成,其他线程都可以该值。

有序性

如果在本线程内观察,所有的操作都是有序的;——as-if-serial

如果其他线程观察,所有操作都是无序的。——指令重排序

程序代码按照先后顺序执行。

volatile可以保证有序性,具体参考volatile

synchronizedLock也可以保证有序性,可以保证同一时刻只有一个线程能执行同步代码,线程可以顺序执行代码

Happens-Before(先行发生)原则

JMM天生具有一定的有序性,不需要任何手段保证有序性,通常这个称为happens-before(先行发生)原则

用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。

如果一个操作的执行结果需要另一操作可见,那么这俩操作必须存在Happen-before关系。

主要有以下几条规则:

程序次序规则

按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。需要考虑分支、循环等结构。

管程锁定规则

一个锁的unlock操作先行发生于lock操作

volatile变量规则

volatile修饰的变量的写操作先行发生于读操作

线程启动规则

Threadstart()先行发生于此线程的每一个动作。

线程中断规则

Threadinterrupt()先行发生于线程中断检测代码Thread.interrupted()

线程终止规则

Thread的所有操作都先行发生于此线程的终止检测,例如Thread.join(),Thread.isAlive()

对象终结规则

对象的初始化完成先行发生于finalize()执行

finalize:垃圾回收器准备释放内存的时候,会先调用finalize(),可以在执行的时候做点工作。

传递性

如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

volatile

线程

Java线程相关合集整理

Class文件结构(字节码)

DVM&ART(Android虚拟机)

参考链接

彻底弄懂Java中的常量池

字符串常量池相关问题

Java四大引用

Java应用的GC优化

ZGC基础概念

ZGC分析


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!