JVM

JVM

JVM:Java Virtual Machine,Java虚拟机

JDK版本从1.3.1开始运用HotSpot虚拟机,2006年底开源,主要使用C++实现,JNI接口部分用C实现。

HotSpot为较新的Java虚拟机,代替JIT(Just in Time),大大提高Java运行性能。;
Java原先是把源代码编译为字节码在虚拟机执行,执行速度较慢,而HotSpot将常用的部分代码编译为本地(原生,native)代码,这样提高了性能。

三种JVM:
Sun: Java HotSpot™ 64-Bit Server VM (build 25.241-b07, mixed mode)
BEA:JRockit
IBM:J9 VM

JVM概述

  • JVM:Java Virtual Machine,也就是Java虚拟机
  • 所谓虚拟机是指:通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的计算机系统
  • JVM是通过软件来模拟Java字节码的指令集,是Java程序的运行环境

JVM主要功能

  • 通过 ClassLoader 寻找和装载 class 文件

  • 解释字节码成为指令并执行,提供 class 文件的运行环境

  • 进行运行期间的内存分配和垃圾回收

  • 提供与硬件交互的平台

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-laUCwEge-1613359072403)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214113615159.png)]

HotSpot基础知识

参考(https://blog.csdn.net/dyr_1203/article/details/83311431)

HotSpot包括一个解释器和两个编译器(client 和 server,二选一的),解释与编译混合执行模式,默认启动解释执行。

编译器:java源代码被编译器编译成class文件(字节码),java字节码在运行时可以被动态编译(JIT)成本地代码(前提是解释与编译混合执行模式且虚拟机不是刚启动时)。

解释器: 解释器用来解释class文件(字节码),java是解释语言。

server启动慢,占用内存多,执行效率高,适用于服务器端应用;

client启动快,占用内存小,执行效率没有server快,默认情况下不进行动态编译,适用于桌面应用程序。

动态编译

动态编译(compile during run-time),英文称Dynamic compilation;Just In Time也是这个意思。

HotSpot对bytecode的编译不是在程序运行前编译的,而是在程序运行过程中编译的,HotSpot里运行着一个监视器(Profile Monitor),用来监视程序的运行状况。

java字节码(class文件)是以解释的方式被加载到虚拟机中(默认启动时解释执行), 程序运行过程中,运用频率大的代码,对性能效率影响重要等程序,程称为热点(hotspot),HotSpot会把这些热点动态地编译成机器码(native code),同时对机器码进行优化,从而提高运行效率。对那些较少运行的代码,HotSpot就不会把他们编译。

HotSpot对字节码有三层处理:不编译(字节码加载到虚拟机中时的状态。也就是当虚拟机执行的时候再编译),编译(把字节码编译成本地代码。虚拟机执行的时候已经编译好了,不要再编译了),编译并优化(不但把字节码编译成本地代码,而且还进行了优化)。

至于那些程序那些不编译,那些编译,那些优化,则是由监视器(Profile Monitor)决定。

面向对象的语言支持多态,静态编译无效确定程序调用哪个方法,因为多态是在程序运行中确定调用哪个方法,静态编译器通常很难准确预知程序运行过程中究竟什么部分最需要优化。

函数调用都是很浪费系统时间的,因为有许多进栈出栈操作。因此有一种优化办法,就是把原来的函数调用,通过编译器的编译,改成非函数调用,把函数代码直接嵌到调用出,变成顺序执行。

JVM内存区域划分

参考(https://blog.csdn.net/qq_46153765/article/details/113092445?ops_request_misc=%25257B%252522request%25255Fid%252522%25253A%252522161285986416780269888151%252522%25252C%252522scm%252522%25253A%25252220140713.130102334…%252522%25257D&request_id=161285986416780269888151&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduend~default-4-113092445.first_rank_v2_pc_rank_v29&utm_term=JVM)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jjmxYJ9p-1613359072405)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214113338305.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PmRZn3pQ-1613359072406)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214180809547.png)]

粗略分来,JVM的内部体系结构分为三部分,分别是:类装载器(ClassLoader)子系统,运行时数据区,和执行引擎。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JDoPrcBV-1613359072407)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214113436475.png)]

类装载器

每一个Java虚拟机都由一个类加载器子系统(class loader subsystem),负责加载程序中的类型(类和接口),并赋予唯一的名字。每一个Java虚拟机都有一个执行引擎(execution engine)负责执行被加载类中包含的指令。JVM的两种类装载器包括:启动类装载器和用户自定义类装载器,启动类装载器是JVM实现的一部分,用户自定义类装载器则是Java程序的一部分,必须是ClassLoader类的子类。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GGFJuB5t-1613359072408)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214113840461.png)]

  • 加载:查找并加载类文件的二进制数据
  • 连接:就是将已经读入内存的类的二进制数据合并到 JVM 运行时环境中去,包含以下步骤:
    • 验证:确保被加载类的正确性
    • 准备:为类的 静态变量 分配内存,并初始化
    • 解析:把常量池中的符号引用转换成直接引用
  • 初始化:为类的静态变量赋初始值

类加载要完成的功能

  • 通过类的全限定名来获取该类的二进制字节流
  • 把二进制字节流转化为方法区的运行时数据结构
  • 在堆上创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构,并向外提供了访问方法区内数据结构的接口

加载类的方式

  • 最常见的方式:本地文件系统中加载、从jar等归档文件中加载
  • 动态的方式:将 java 源文件动态编译成 class
  • 其他方式:网络下载、从专有数据库中加载等等

类加载器

  • Java 虚拟机自带的加载器包括以下几种:

    • 启动类加载器(BootstrapClassLoader)
    • 平台类加载器(PlatformClassLoader) JDK8:扩展类加载器(ExtensionClassLoader)
    • 应用程序类加载器(AppClassLoader)
  • 用户自定义的加载器:是 java.lang.ClassLoader 的子类,用户可以定制类的加载方式;只不过自定义类加载器其加载的顺序是在所有系统类加载器的最后

类加载器的关系

在这里插入图片描述

双亲委派机制

如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,

如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,

如果父类加载器可以完成类加载任务,就成功返回,

倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载。

根类加载器←扩展加载器←用户加载器←自定义的类加载器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4EGUdVQO-1613359072409)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214183555796.png)]

类连接和初始化

类连接主要验证的内容

  • 类文件结构检查:按照 JVM 规范规定的类文件结构进行
  • 元数据验证:对字节码描述的信息进行语义分析,保证其符合 Java 语言规范要求
  • 字节码验证:通过对数据流和控制流进行分析,确保程序语义是合法和符合逻辑的。这里主要对方法体进行校验
  • 符号引用验证:对类自身以外的信息,也就是常量池中的各种符号引用,进行匹配校验

类连接中的准备

  • 为类的 静态变量 分配内存,并初始化

类连接中的解析

  • 解析就是把常量中的符号引用转换成直接引用的过程,包括:符号引用:以一组无歧义(唯一)的符号来描述所引用的目标,与虚拟机的失效无关
  • 直接引用:直接执行目标的指针、相对偏移量、或是能间接定位到目标的句柄,是和虚拟机实现相关的
  • 主要针对:类、接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符

类的初始化

  • 类的初始化就是为类的静态变量赋初始值,或者说是执行类构造器 方法的过程

    1. 如果类还没有加载和连接,就先加载和连接

    2. 如果类存在父类,且父类没有初始化,就先初始化父类

    3. 如果类中存在初始化语句,就依次执行这些初始化语句

    4. 如果是接口的话:

      1. 初始化一个类的时候,并不会先初始化它实现的接口
      2. 初始化一个接口的时候,并不会先初始化它的父接口
      3. 只有当程序首次使用接口里面的变量或者是调用接口方法的时候,才导致接口初始化
    5. 调用 Classloader 类的 loadClass 方法类装载一个类,并不会初始化这个类,不是对类的主动使用

类的主动初始化

类的初始化时机

  • Java 程序对类的使用方式分成:主动使用和被动使用,JVM 必须在每个类或接口 ”首次主动使用“ 时才初始化它们;被动使用类不会导致类的初始化,主动使用的情况:

    1. 创建类实例

    2. 访问某个类或接口的静态变量

    3. 调用类的静态方法

    4. 反射某个类

    5. 初始化某个类的子类,而父类还没有初始化

    6. JVM 启动的时候运行的主类

    7. 定义了 default 方法的接口,当接口实现类初始化时

类加载器

ClassLoader 顾名思义就是类加载器,ClassLoader 作用

  • 负责将 Class 加载到 JVM 中
  • 审查每个类由谁加载(父优先的等级加载机制)
  • 将 Class 字节码重新解析成 JVM 统一要求的对象格式

类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。

img

  • 加载:通过一个类的全限定名来获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构****转化为方法区的运行时数据结构****,****在内存中生成一个代表这个类的Class对象****,作为方法去这个类的各种数据的访问入口
  • 验证:验证是连接阶段的第一步,这一阶段的****目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求****,并且不会危害虚拟自身的安全。
  • 准备:准备阶段是正式为类变量分配内存设置类变量初始值的阶段,这些变量所使用的内存都将在方法去中进行分配。这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  • 解析:解析阶段是虚拟机将常量池内的符号(Class文件内的符号)引用替换为**直接引用(指针)**的过程。
  • 初始化:初始化阶段是类加载过程的最后一步,开始执行类中定义的Java程序代码(字节码)
加载
  1. 将class文件加载到内存
  2. 将静态数据结构转化成方法区中运行时的数据结构
  3. 在堆中生成一个代表这个类的 java.lang.Class对象作为数据访问的入口
链接
  1. 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查
  2. 准备:为static变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的)
  3. 解析:虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在import java.util.ArrayList这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行)
初始化

初始化其实就是一个赋值的操作,它会执行一个类构造器的()方法。由编译器自动收集类中所有变量的赋值动作,此时准备阶段时的那个 static int a = 3 的例子,在这个时候就正式赋值为3

卸载

GC将无用对象从内存中卸载

类加载器的加载顺序

加载一个Class类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

  1. BootStrap ClassLoader:rt.jar
  2. Extention ClassLoader: 加载扩展的jar包
  3. App ClassLoader:指定的classpath下面的jar包
  4. Custom ClassLoader:自定义的类加载器

双亲委派机制

当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要new一个Person,这个Person是我们自定义的类,如果我们要加载它,就会先委派App ClassLoader,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的Class)时,子类加载器才会自行尝试加载

这样做的好处是,加载位于rt.jar包中的类时不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。

其实这个也是一个隔离的作用,避免了我们的代码影响了JDK的代码,比如我现在要来一个

public class String(){
    public static void main(){sout;}
}

这种时候,我们的代码肯定会报错,因为在加载的时候其实是找到了rt.jar中的String.class,然后发现这也没有main方法

对象的创建过程是什么样的?

img

  • 类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程

  • 分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  • 初始化零值

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

  • 设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 ****这些信息存放在对象头中****。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  • 执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

执行引擎:它或者在执行字节码,或者执行本地方法

主要的执行技术有:解释,即时编译,自适应优化、芯片级直接执行其中解释属于第一代JVM,即时编译JIT属于第二代JVM,自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式 。

自适应优化:开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行仔细优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。

运行时数据区:主要包括:方法区,堆,Java栈,PC寄存器,本地方法栈

运行时数据区域(内存模型)

  • 内存区域是指 Jvm 运行时将数据分区域存储,强调对内存空间的划分。
  • 而**内存模型(Java Memory Model,简称 JMM )**是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。

一、内存模型

内存模型(Java Memory Model,简称 JMM ),JMM 定义了 JVM 在计算机内存(RAM)中的工作方式;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WM8Kqmkv-1613359072410)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210210135355610.png)]

JDK8之前包括:方法区、程序计数器、java虚拟机栈、本地方法栈、堆,其中方法区和堆事线程共享,程序计数器和栈是线程私有;

参考(https://blog.csdn.net/xiaofeng10330111/article/details/105360974?ops_request_misc=%25257B%252522request%25255Fid%252522%25253A%252522161285986416780269888151%252522%25252C%252522scm%252522%25253A%25252220140713.130102334…%252522%25257D&request_id=161285986416780269888151&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_click~default-1-105360974.first_rank_v2_pc_rank_v29&utm_term=JVM)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RqN9Vh4z-1613359072410)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210210135837694.png)]

JSD8之后,类的元数据放到本地堆内存(native heap)中,这一块区域就叫 Metaspace,中文名叫元空间。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eFUl5gvc-1613359072411)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210210135903865.png)]

参考(https://blog.csdn.net/NCS123456/article/details/84914198?ops_request_misc=&request_id=&biz_id=102&utm_term=JVM%2520%2520Metaspace&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduweb~default-7-84914198.first_rank_v2_pc_rank_v29)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VE3GOkgX-1613359072412)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210210113629732.png)]

1、去除了PermGen,Native Memory中新增了Metaspace;

2、静态变量和常量移到了Heap Space;

3、类元数据移到了Metaspace

移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。我们可以通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 的区别,以字符串常量为例:

Metaspace 和 PermGen 比较

PermGen的劣势

永久代指存在于HotSpotJVM中,其他JVM不存在永久代

1、固定的PermSize,大小很难确定并且很难进行扩展 -XX:MaxPermSize -XX:PermSize

2、在进行full GC时PermGen中的class meta对象有可能会被移动

3、发生java.lang.OutOfMemoryError: PermGen error时应用程序要清除与class关联的所有引用要么更改MaxPermSize重启应用

4、需要meta-classmeta对象对classmeta对象进行描述

5、垃圾回收效率较低,需要进行对整个PermGen进行扫描

Metaspace的优势

1、类和类元数据的生命周期与类加载器一致

2、Metaspace的空间分配是线性的,可随类加载的数量进行线性的扩展,默认情况下只与native memory大小有关

3、元数据的位置在native memory中的位置是固定的

4、GC时不会对metaspace空间进行扫描,节省了扫描和压缩的时间(如果设置了Metaspace的大小,当到达该Metaspace的阀值也会进行full GC)

5、减小了full gc的时间

为什么废弃永久代

1、官方说明:

This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.

即:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。

2、经常内存溢出

由于永久代内存经常不够用或发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen

为什么要使用元空间取代永久代的实现?

  • 字符串存在永久代中,容易出现性能问题和内存溢出。
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  • 将 HotSpot 与 JRockit 合二为一。

1、方法区:

方法区(Method Area)对应PermanentGeneration(持久代)、存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,即储存 类信息(构造方法,接口)、常量、静态变量,静态代码块、常量池,即时编译器(JIT Compiler)编译后的代码数据,线程共享区。别名 Non-Heap(非堆),与 Java 堆区分开来。

  • 方法区 (Method Area) 与 Java 堆一样, 是各个线程共享的内存区域。

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

  • 运行时常量池 (Runtime Constant Pool) 是方法区的一部分。

  • 虽然 JVM规范把方法区描述为堆的一个逻辑部分, 但是它却又一个别名叫做 Non-Heap(非堆), 目的应该是与 Java 堆区分开来.

  • 方法区 和 永久代(Permanent Generation), 本质上两者并不相等。

    和堆一样, 允许固定大小, 也允许可扩展的大小, 还可以选择不实现垃圾回收。 相对而言, 垃圾收集行为在这个区域是比较少出现的, 但是并非数据进入了方法区就如同进入永久代的名字一样” 永久” 存在了。

    这区域的内存回收目标主要是针对常量池的回收和对类型的卸载, 一般来说, 这个区域的回收” 成绩” 比较难以令人满意, 尤其是对类型的卸载, 条件相当苛刻, 但是这部分区域的回收确实是存在必要的。

  • 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

字面量
比较接近 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。(final 修饰的成员变量和类变量【类变量:静态成员变量】)

符号引用
符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。
你比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。
当第一次运行时,要根据字符串的内容,到该类的方法表中搜索这个方法。
运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。
直接引用就是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置。

一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

  • 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

变量总结

参考(https://www.pianshen.com/article/5587137445/)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HaRNYZPu-1613359072412)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210211124042692.png)]

常量池
常量池

常量池的实现为一个固定大小的hash字典, 每个桶里包含一个具有相同hash值的字符串数组。

jdk7之前常量池是在永生代中, 从jdk7开始常量池从永生代移除, 放到了堆中。

jdk6中常量池默认大小为1009,jdk6早期这个不可配置, jdk6u30到jdk6u41可配置。而在jdk7中从jdk7u02开始可以配置。从jdk7u40开始, 常量池的默认大小为60013。

参考(https://developer.aliyun.com/article/49481)

简介

常量池在java用于保存在编译期已确定的,已编译的class文件中的一份数据。它包括了关于类,方法,接口等中的常量,也包括字符串常量,如String s = "java"这种申明方式;当然也可扩充,执行器产生的常量也会放入常量池,故认为常量池是JVM的一块特殊的内存空间。
Java是一种动态链接的语言,常量池的作用非常重要,常量池中除了包含代码中所定义的各种基本类型(如int、long等等)和对象型(如String及数组)的常量值外,还包含一些以文本形式出现的符号引用,比如:

类和接口的全限定名;

字段的名称和描述符;

方法的名称和描述符。

在C语言中,如果一个程序要调用其它库中的函数,在链接时,该函数在库中的位置(即相对于库文件开头的偏移量)会被写在程序中,在运行时,直接去这个地址调用函数;

而在Java语言中不是这样,一切都是动态的。编译时,如果发现对其它类方法的调用或者对其它类字段的引用的语句,记录进class文件中的只能是一个文本形式的符号引用,在连接过程中,虚拟机根据这个文本信息去查找对应的方法或字段。

所以,与Java语言中的所谓“常量”不同,class文件中的“常量”内容很丰富,这些常量集中在class中的一个区域存放,一个紧接着一个,这里就称为“常量池”。

java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外Byte,Short,Integer,Long,Character这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

java中的常量池技术,是为了方便快捷地创建某些对象而出现的,当需要一个对象时,就可以从池中取一个出来(如果池中没有则创建一个),则在需要重复重复创建相等变量时节省了很多时间。常量池其实也就是一个内存空间,不同于使用new关键字创建的对象所在的堆空间。

常量池中对象和堆中的对象:
public class Test{
	
	public static void main(String[] args){
		Integer i1=new Integer(1);
		   Integer i2=new Integer(1);
		//i1,i2分别位于堆中不同的内存空间

		   System.out.println(i1==i2);//输出false


		   Integer i3=1;
		   Integer i4=1;
		//i3,i4指向常量池中同一个内存空间

		   System.out.println(i3==i4);//输出true

		//很显然,i1,i3位于不同的内存空间

		System.out.println(i1==i3);//输出false
	}
}
8种基本类型的包装类和对象池:
public class Test{
	
	public static void main(String[] args){
		   //5种整形的包装类Byte,Short,Integer,Long,Character的对象,

		   //在值小于127时可以使用常量池
		   Integer i1=127;
		   Integer i2=127;
		   System.out.println(i1==i2);//输出true

		   //值大于127时,不会从常量池中取对象
		   Integer i3=128;
		   Integer i4=128;

		   System.out.println(i3==i4);//输出false

		   //Boolean类也实现了常量池技术
		   Boolean bool1=true;
		   Boolean bool2=true;
		   System.out.println(bool1==bool2);//输出true
		   //浮点类型的包装类没有实现常量池技术

		   Double d1=1.0;
		   Double d2=1.0;
		   System.out.println(d1==d2);//输出false

		}
}
String也实现了常量池技术:
public class Test{

public static void main(String[] args){
//s1,s2分别位于堆中不同空间

String s1=new String("hello");
String s2=new String("hello");

System.out.println(s1==s2)//输出false

//s3,s4位于池中同一空间

String s3="hello";
String s4="hello";

System.out.println(s3==s4);//输出true

}
}

用new String()创建的字符串不是常量,不能在编译期就确定,所以new String()创建的字符串不放入常量池中,他们有自己的地址空间。

String 对象(内存)的不变性机制会使修改String字符串时,产生大量的对象,因为每次改变字符串,都会生成一个新的String。 java 为了更有效的使用内存,常量池在编译期遇见String 字符串时,它会检查该池内是否已经存在相同的String 字符串,如果找到,就把新变量的引用指向现有的字符串对象,不创建任何新的String 常量对象,没找到再创建新的。所以对一个字符串对象的任何修改,都会产生一个新的字符串对象,原来的依然存在,等待垃圾回收。

基于JDK1.8的常量池

参考(https://blog.csdn.net/qq_31615049/article/details/81611918)

java中的常量池分为三种类型:

  • 类文件中常量池(The Constant Pool)
  • 运行时常量池(The Run-Time Constant Pool)
  • String常量池
类文件中常量池 ---- 存在于Class文件中

所处区域:堆

诞生时间:编译时

内容概要:符号引用和字面量

class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。

常量池中存放的是符号信息,java虚拟机在执行指令的时候会依赖这些信息。常量池中的所有项都具有如下通用格式:

cp_info {
 u1 tag;     //表示cp_info的单字节标记位
 u1 info[];  //两个或更多的字节表示这个常量的信息,信息格式由tag的值确定
}
Constant TypeValue
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_InvokeDynamic18

举几个典型的例子来说明常量池中数据是如何存储的:

CONSTANT_Class结构 -- 表示类或者接口,他的格式如下:
CONSTANT_Class_info {
 u1 tag;       //这个值为 CONSTANT_Class (7)
 u2 name_index;//注意这是一个index,他表示一个索引,引用的是CONSTANT_UTF8_info
}

注意观察 这个CONSTANT_Class_info类型的常量内部结构是由一个tag(CONSTANT_Class(7))和一个name_index组成,name_index中注意这个index,他表示一个索引的,什么的索引呢?CONSTANT_Utf8_info结构的索引,这个结构用来表示一个有效的类或者接口的二进制名称的内部形式。class文件结构中出现的类或者接口名称都是通过全限定形式来表示的,也被称作二进制名称【题外话:全限定类名含义就类似 java.lang包中定义的Object类的完全限定名称为java.lang.Object``】。

那我们接着看CONSTANT_Utf8_info结构,他用于表示字符常量的值,他的结构如下所示:
CONSTANT_Utf8_info {
 u1 tag;
 u2 length;
 u1 bytes[length];
}

我们注意到第一个tag肯定表示为:CONSTANT_Utf8(1);后面的length指明了bytes[]数组的长度;最后一个bytes[]数组引用了上一个length作为其长度。字符常量采用改进过的UTF-8编码表示。

运行时常量池 ---- 存在于内存的元空间中

诞生时间:JVM运行时

内容概要:class文件元信息描述,编译后的代码数据,引用类型数据,类文件常量池。

所谓的运行时常量池其实就是将编译后的类信息放入运行时的一个区域中,用来动态获取类信息。

运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

字符串常量池 ---- 存在于堆中

从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。

字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)。 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

运行时常量池
  • 是Class文件中每个类或接口的常量池表,在运行期间的表示形式,通常包括:类的版、字段、方法、接口等信息
  • 在方法区中分配
  • 通常在加载类和接口到JVM后,就创建相应的运行时常量池

运行时常量池 ---- 存在于内存的元空间中

诞生时间:JVM运行时

内容概要:class文件元信息描述,编译后的代码数据,引用类型数据,类文件常量池。

所谓的运行时常量池其实就是将编译后的类信息放入运行时的一个区域中,用来动态获取类信息。

运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

字符串常量池 ---- 存在于堆中

从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。

字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的)。 在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

总结

通过上面分析,大家应该大致了解了 JVM 的内存划分,也清楚了 JDK 8 中永久代向元空间的转换。不过大家应该都有一个疑问,就是为什么要做这个转换?所以,最后给大家总结以下几点原因:

1、字符串存在永久代中,容易出现性能问题和内存溢出。

2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

4、Oracle 可能会将HotSpot 与 JRockit 合二为一。

2、堆:

存放对象实例(数组/对象),对象实例都在这里分配内存,栈里的或者方法区静态对象只是引用,实际对象内存分配都在堆里进行,因此是垃圾回收(GC)主要区域,堆中可能划分出多个线程私有的缓冲区(Thread Local Allocation Buffer,TLAB);

Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配。

TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

  • 对于大多数应用来说,Java 堆 (Java Heap) 是 JVM所管理的内存中最大的一块。
  • Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
  • 此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
  • 数组引用变量是存放在内存中,数组元素是存放在内存中。
  • Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称作为 “GC 堆”。
  • 从内存回收的角度看,Java 堆中还可以细分为: 新生代老年代
  • 程序新创建的对象都是从新生代分配内存,新生代由 Eden Space 和两块相同大小的 Survivor Space(通常又称 S0 和 S1 或 From 和 To) 构成。
  • 详见JVM常见参数设置
  • 从内存分配角度,线程共享的 Java 堆可能划分出多个线程私有的分配缓冲区(TLAB)。
  • Java 堆可以处于物理不连续的内存空间中,只要逻辑是连续的即可,就像我们的磁盘空间一样。
  • 在实现时,即可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的 (通过 -Xmx 和 -Xms 控制)。
  • 如果堆上没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError异常。

一个JVM只有一个堆内存

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ib7yuo0j-1613359072413)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210212123921091.png)]

堆内存分区:

  1. 新生区 Young/New

  2. 养老区 old

  3. 永久区 Perm

当我们new一个对象后,会先放到Eden划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里JVM的处理是每个线程都会预先申请好一块连续的内存空间并规定了对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作我们会称作TLAB,有兴趣可以了解一下。

当Eden空间满了之后,会触发一个叫做Minor GC(就是一个发生在年轻代的GC)的操作,存活下来的对象移动到Survivor0区。Survivor0区满后触发 Minor GC,就会将存活对象移动到Survivor1区,此时还会把from和to两个指针交换,这样保证了一段时间内总有一个survivor区为空且to所指向的survivor区为空。经过多次的 Minor GC后仍然存活的对象(这里的存活判断是15次,对应到虚拟机参数为 -XX:TargetSurvivorRatio 。为什么是15,因为HotSpot会在对象投中的标记字段里记录年龄,分配到的空间仅有4位,所以最多只能记录到15)会移动到老年代。老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题。

HotSpot 虚拟机默认 Eden:Survivor = 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(其中一块Survivor不可用),只有 10% 的内存会被“浪费”。

3、Java栈

虚拟机只会直接对Javastack执行两种操作:以帧为单位的压栈或出栈,每个帧代表一个方法,Java方法有两种返回方式,return和抛出异常,两种方式都会导致该方法对应的帧出栈和释放内存。

每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

帧的组成:局部变量区(包括方法参数和局部变量,对于instance方法,还要首先保存this类型,其中方法参数按照声明顺序严格放置,局部变量可以任意放置),操作数栈,帧数据区(用来帮助支持常量池的解析,正常方法返回和异常处理)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ev59dOEG-1613359072414)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210210140242118.png)]

局部变量表

局部变量表是存放方法参数和局部变量的区域, 没有准备阶段, 必须显式初始化;如果是非静态方法,则在 index[0] 位置上存储的是方法所属对象的实例引用,一个引用变量占 4 个字节,随后存储的是参数和局部变量。

字节码指令中的 STORE 指令就是将操作栈中计算完成的局部变呈写回局部变量表的存储空间内。

虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,则抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,则会抛出 OutOfMemoryError 异常。

参考(https://www.pianshen.com/article/5587137445/)

定义

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数方法内部定义的局部变量

编译器确定容量

在Java程序编译为class文件时,就在方法的Code属性的 max_locals 数据项中确定了 该方法所需要分配的局部变量表的最大容量。

最小单位为变量槽(Slot)

一个Slot 可以存放一个32位以内的数据类型,包括基本数据类型 (boolean、byte、char、short、int、float、long、double)「String 是引用类型」,对象引用 (reference 类型) 和 returnAddress 类型(它指向了一条字节码指令的地址)。

操作栈

操作栈是个初始状态为空的桶式结构栈,在方法执行过程中, 会有各种指令往栈中写入和提取信息。JVM 的执行引擎是基于栈的执行引擎, 其中的栈指的就是操作栈。字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的 stack 属性中。

i++ 和 ++i 的区别

i++:从局部变量表取出 i 并压入操作栈,然后对局部变量表中的 i 自增 1,将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,如此线程从操作栈读到的是自增之前的值。

++i:先对局部变量表的 i 自增 1,然后取出并压入操作栈,再将操作栈栈顶值取出使用,最后,使用栈顶值更新局部变量表,线程从操作栈读到的是自增之后的值。

之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。

详细例子参考:
(https://zhuanlan.zhihu.com/p/99099719)

public static void main(String[] args) {
    int i = 0;
    for (int j = 0; j < 50; j++) {
        i = i++;
    }
    System.out.println(i);
}

输出结果是0,而不是50;字节码如下

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_0
         1: istore_1
         2: iconst_0
         3: istore_2
         4: iload_2
         5: bipush        50
         7: if_icmpge     21
        10: iload_1
        11: iinc          1, 1
        14: istore_1
        15: iinc          2, 1
        18: goto          4
        21: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
        24: iload_1
        25: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        28: return

先抛出这个方法局部变量表

img

pc 0~1两个指令,为下标为1的局部变量赋值为0,也就是局部变量i

pc 2~3两个指令,为下表为2的局部变量赋值为0,也就是局部变量j

pc 4~7三个指令,取局部变量j的值与50比较,如果j>=50,跳转到pc=21的指令处,如果不满足则顺序往下执行

pc 10~14三个指令,是i=i++这行代码编译后的指令。在jvm中,局部变量表和操作数栈是两个不同的存储数据的内存区域。iload_1表示将局部变量表中下标为1的变量,也就是变量i的值复制一份,加载到操作数栈顶,innc 1,1 指令则将局部变量表中变量i的值加1再写回局部变量表中变量i的位置,istore_1则将栈顶的数据覆盖局部变量表中变量i的位置,所以执行完这3个命令后,变量i的值并没有发生变化。用伪代码来表示这三个指令的逻辑就是这样

int stack_top = local_variable[1];//把下标为1的局部变量加载到栈顶
local_variable[1] = local_variable[1] + 1;//下标为1的局部变量自增1
local_variable[1] = stack_top;//用栈顶的值覆盖下标为1的局部变量

pc 15指令iinc 2,1 将变量j自增1

pc 18指令goto 4,程序重新从pc=4的地方开始执行

pc 21~25三个指令,就是打印下标为1的局部变量,也就是打印变量i

所以,从pc10~14三个指令,可以看出变量i=i++这行代码不会改变变量i的值,因此最后打印结果是0。

如果将 i=i++ 改成 i=++i,结果会是怎样呢?

public static void main(String[] args) {
    int i = 0;
    for (int j = 0; j < 50; j++) {
        i = ++i;
    }
    System.out.println(i);
}

输出结果是50,还是直接看字节码

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_0
         1: istore_1
         2: iconst_0
         3: istore_2
         4: iload_2
         5: bipush        50
         7: if_icmpge     21
        10: iinc          1, 1
        13: iload_1
        14: istore_1
        15: iinc          2, 1
        18: goto          4
        21: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
        24: iload_1
        25: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        28: return

除了指令10~14,对应的代码就是 i=++i,其他部分跟上面的字节码指令一样,所以我们只看不一样的部分。

pc 10 innc 1,1 这里先执行自增指令,将下标为1的局部变量i的值自增1

pc 13 iload_1 将下标为1的局部变量i的值加载到操作数栈顶

pc 14istore_1 将操作数栈顶的值覆盖下标为1的局部变量i的值

用伪代码来表示这段逻辑就是这样

local_variable[1] = local_variable[1] + 1;//下标为1的局部变量自增1
int stack_top = local_variable[1];//把下标为1的局部变量加载到栈顶
local_variable[1] = stack_top;//用栈顶的值覆盖下标为1的局部变量

所以,从pc10~14三个指令,可以看出变量 i=++i 这行代码会使i的值增加1,因此最后打印结果是50。

与i++对应的指令不同的地方是,++i会先执行innc 1,1指令,这条指令会是i的值增加1,然后再参与计算。而i++会先将i的值保存到另外一个地方,然后再对i自增1,但是i=i++的赋值(也就是=)会用已保存的i的旧值覆盖i的新值,所以i=i++,i的值并不会变。

总结:讲解了i=i++和i=++i的字节码底层原理。

动态链接

每个栈帧中包含一个在常量池中对当前方法的引用, 目的是支持方法调用过程的动态连接。

方法返回地址

方法执行时有两种退出情况:正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;异常退出。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:返回值压入上层调用栈帧/异常信息抛给能够处理的栈帧/PC计数器指向方法调用后的下一条指令。

4、本地方法栈

参考(https://blog.csdn.net/uk8692/article/details/50680159)

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

Sun HotSpot 虚拟机直接就把本地方法栈和虚拟机栈合二为一,与java虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

线程开始调用本地方法时,会进入不再受 JVM 约束的世界。本地方法可以通过 JNI(Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和 JVM 相同的能力和权限,当大量本地方法出现时,势必会削弱 JVM 对系统的控制力,因为它的出错信息都比较黑盒。对内存不足的情况,本地方法栈还是会抛出 nativeheapOutOfMemory。

前面提到的所有运行时数据区都是Java虚拟机规范中明确定义的,除此之外,对于一个运行中的Java程序而言,他还可能会用到一些本地方法相关的数据区。当某个线程调用一个本地方法时,他就进入了一个全新的并且不再受虚拟机限制的世界 ,本地方法可以通过本地方法接口 来访问虚拟机得运行时数据区,但不止于此,他还可以做任何他想做的事情。比如,他甚至可以直接使用本地处理器中的寄存器,或者直接从本地内存的堆中分配任意数量的内存等等。总之,他和虚拟机拥有同样的权限(或者说能力)。

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入java栈。然而当他调用的是本地方法时,虚拟机会保持Java栈不变 ,不再在线程的java栈中压入新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。可以把这看做是虚拟机利用本地方法来动态扩展自己 。就如同Java虚拟机的实现在按照其中运行的Java程序的吩咐,调用属于虚拟机内部的另一个(动态连接的)方法。

如果某个虚拟机实现的本地方法接口是使用C连接模型的话,那个他的本地方法栈就是C栈。我们知道,当C程序调用一个C函数时,其栈操作都是确定的。传递 给该函数的参数已某个确定的顺序压入栈,他的返回值也以确定的方式传回调用者。同样,这就是改虚拟机实现中本地方法栈的行为。

很可能本地方法接口需要回调Java虚拟机中的Java方法(这也是由设计者决定的),在这种情形下,该线程会保存本地方法栈的状态并进入到另一个Java栈。

下图描绘了这种情况,就是当一个线程调用一个本地方法时,本地方法又回调虚拟机中的另一个Java方法。这幅图展示了java虚拟机内部线程运行的全景 图。一个线程可能在整个生命周期中都执行Java方法,操作他的Java栈;或者他可能毫无障碍地在Java栈和本地方法栈之间跳转。
这里写图片描述

JNI 类本地方法最著名的应该是 System.currentTimeMillis() ,JNI使 Java 深度使用操作系统的特性功能,复用非 Java 代码。 但是在项目过程中, 如果大量使用其他语言来实现 JNI , 就会丧失跨平台特性。

上图所示,该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。图中的本地方法栈显示为 一个连续的内存空间。假设这是一个C语言栈,期间有两个C函数,他们都以包围在虚线中的灰色块表示。第一个C函数被第二个Java方法当做本地方法调用, 而这个C函数又调用了第二个C函数。之后第二个C函数又通过 本地方法接口回调了一个Java方法(第三个Java方法)。最终这个Java方法又调用了一个Java方法(他成为图中的当前方法)。

就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,他可以根据需要动态扩展或者收缩。某些是实现也允许用户或者程序员指定该内存区的初始大小以及最大,最小值。

5、程序计数器:

(PC寄存器)用来存储指向下一条指令的地址,,如果正在执行的是 Native 方法,这个计数器值则为空(Undefined),因为不负责本地方法栈;程序分支/循环/跳转/线程恢复等操作都依靠这个指针。

  • 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

  • 字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都要依赖这个计数器来完成。

  • 每条线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储。如上图所示,我们称这类内存区域为 : 线程私有内存。

  • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。

  • 此内存区域是唯一一个在 Java 虚拟机中没有规范任何 OutOfMemoryError 情况的区域。

栈、堆、方法区交互关系

参考(https://blog.csdn.net/qq_46153765/article/details/113092445?ops_request_misc=%25257B%252522request%25255Fid%252522%25253A%252522161285986416780269888151%252522%25252C%252522scm%252522%25253A%25252220140713.130102334…%252522%25257D&request_id=161285986416780269888151&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduend~default-4-113092445.first_rank_v2_pc_rank_v29&utm_term=JVM)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xdi6rv4v-1613359072416)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210211115925877.png)]

对象内存布局

  • 对象在内存中储存的布局(这里以Hotspot虚拟机为例来说明),分为:对象头、实例数据和对齐填充

  • 对象头,包含两部分:

    • Mark Word:存储对象自身的运行数据,如:HashCode、GC分代年龄,锁状态标志等
    • 类型指针:对象指向它的类元数据的指针
  • 实例数据:真正存放对象实例数据的地方

  • 对齐填充:这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为 HotSpot 要求对象起始地址都是8字节的整数倍,如果不是,就对齐

对象的访问定位

  • 使用句柄:Java堆中会划分出一块内存来作为句柄池,reference 中存储句柄的地址,句柄中存储对象的实例数据和类元数据的地址,如图

在这里插入图片描述

  • 使用指针:Java堆中会存放访问类元数据的地址,reference存储的就直接是对象的地址,如图:

在这里插入图片描述

JVM主内存与工作内存

Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。Java 内存模型(JMM)控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。

*计算机高速缓存和缓存一致性*

计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。

在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。

img

JVM主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。

Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。

**就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。**不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:

img

这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

重排序和happens-before规则

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。java 编译器禁止处理器重排序是通过在生成指令序列的适当位置会插入内存屏障(重排序时不能把后面的指令重排序到内存屏障之前的位置)指令来实现的。

happens-before

从 JDK5 开始,java 内存模型提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

如果 A happens-before B,那么 Java 内存模型将向程序员保证—— A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。

重要的 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

垃圾回收

参考(https://blog.csdn.net/xiaofeng10330111/article/details/105360974?ops_request_misc=%25257B%252522request%25255Fid%252522%25253A%252522161285986416780269888151%252522%25252C%252522scm%252522%25253A%25252220140713.130102334…%252522%25257D&request_id=161285986416780269888151&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_click~default-1-105360974.first_rank_v2_pc_rank_v29&utm_term=JVM)

参考(https://www.cnblogs.com/max-home/p/12270183.html)

图中程序计数器、虚拟机栈、本地方法栈,3个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而Java堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存。

在进行回收前就要判断哪些对象还存活,哪些已经死去。下面介绍两个基础的计算方法

1.引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于0时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时GC没法回收。

2.可达性分析计算:这是一种类似于二叉树的实现,将一系列的GC ROOTS作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。主流的商用程序语言,例如Java,C#等都是靠这招去判定对象是否存活的。

(了解一下即可)在Java语言汇总能作为GC Roots的对象分为以下几种:

  1. 虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)
  2. 方法区中静态变量所引用的对象(静态变量)
  3. 方法区中常量引用的对象
  4. 本地方法栈(即native修饰的方法)中JNI引用的对象(JNI是Java虚拟机调用对应的C函数的方式,通过JNI函数也可以创建新的Java对象。且JNI对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收)
  5. 已启动的且未终止的Java线程

这种方法的优点是能够解决循环引用的问题,可它的实现需要耗费大量资源和时间,也需要GC(它的分析过程引用关系不能发生变化,所以需要停止所有进程)

垃圾回收主要关注 Java 堆

Java 内存运行时区域中的程序计数器、虚拟机栈、本地方法栈随线程而生灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由 JIT 编译器进行一些优化),因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

而 Java 堆不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

判断哪些对象需要被回收有以下两种方法:

  • 引用计数法

给对象添加一引用计数器,被引用一次计数器值就加 1;当引用失效时,计数器值就减 1;计数器为 0 时,对象就是不可能再被使用的,简单高效,缺点是无法解决对象之间相互循环引用的问题。

  • 可达性分析算法

通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。此算法解决了上述循环引用的问题。

img

*在Java语言中,可作为 GC Roots 的对象包括下面几种:*

  • a. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • b. 方法区中类静态属性引用的对象。
  • c. 方法区中常量引用的对象。
  • d. 本地方法栈中 JNI(Native方法)引用的对象

作为 GC Roots 的节点主要在全局性的引用与执行上下文中。要明确的是,tracing gc必须以当前存活的对象集为 Roots,因此必须选取确定存活的引用类型对象。

GC 管理的区域是 Java 堆,虚拟机栈、方法区和本地方法栈不被 GC 所管理,因此选用这些区域内引用的对象作为 GC Roots,是不会被 GC 所回收的。其中虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是 GC roots 的一部分。

可达性分析算法

不可达的对象将暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  • 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。
  • 当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,直接进行第二次标记。
  • 如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。

这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,因为如果一个对象在 finalize() 方法中执行缓慢,将很可能会一直阻塞 F-Queue 队列,甚至导致整个内存回收系统崩溃。

值得注意的是,****使用 finalize() 方法来“拯救”对象是不值得提倡的****,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。finalize() 能做的工作,使用 try-finally 或者其它方法都更适合、及时。

强、软、弱、虚引用

JDK1.2 以前,一个对象只有被引用和没有被引用两种状态。后来,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

img

  1. 强引用就是指在程序代码之中普遍存在的,类似"Object obj=new Object()"这类的引用,垃圾收集器永远不会回收存活的强引用对象。
  2. 软引用:还有用但并非必需的对象。在系统 将要发生内存溢出异常之前 ,将会把这些对象列进回收范围之中进行第二次回收。
  3. 弱引用也是用来描述非必需对象的,被弱引用关联的对象 只能生存到下一次垃圾收集发生之前 。当垃圾收集器工作时,无论内存是否足够,都会回收掉只被弱引用关联的对象。
  4. 虚引用是最弱的一种引用关系。 无法通过虚引用来取得一个对象实例 。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

GC的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停

(1)对新生代的对象的收集称为minor GC;

(2)对旧生代的对象的收集称为Full GC;

(3)程序中主动调用System.gc()强制执行的GC为Full GC。

不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:

(1)强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)

(2)软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)

(3)弱引用:在GC时一定会被GC回收

(4)虚引用:由于虚引用只是用来得知对象是否被GC

Java 堆永久代的回收

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

  1. 回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做"abc"的,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个"abc"常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
  2. *类需要同时满足下面 3 个条件才能算是“无用的类”:*
    a. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
    b. 加载该类的 ClassLoader 已经被回收。
    c. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。

在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾回收算法

一共有 4 种:标记-清除算法、复制算法、标记整理算法、分代收集算法

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

img

它的主要不足有两个:

  • 效率问题,标记和清除两个过程的效率都不高;
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。复制算法的执行过程如下图:

img

****现在的商业虚拟机都采用这种算法来回收新生代****,IBM 研究指出新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。

当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden:Survivor = 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(其中一块Survivor不可用),只有 10% 的内存会被“浪费”。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

标记-整理算法

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后****直接清理掉端边界以外的内存**。**

img

分代收集算法

当前商业虚拟机的垃圾收器都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不用的垃圾收集算法。

一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

了解各种各样的垃圾回收器

HotSpot VM中的垃圾回收器,以及适用场景

img

到jdk8为止,默认的垃圾收集器是Parallel Scavenge 和 Parallel Old

从jdk9开始,G1收集器成为默认的垃圾收集器
目前来看,G1回收器停顿时间最短而且没有明显缺点,非常适合Web应用。在jdk8中测试Web应用,堆内存6G,新生代4.5G的情况下,Parallel Scavenge 回收新生代停顿长达1.5秒。G1回收器回收同样大小的新生代只停顿0.2秒。

Minor GC和Full GC触发条件

Minor GC触发条件:

  • 当Eden区满时,触发Minor GC。

*Full GC触发条件:*

  • System.gc()方法的调用
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  • 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

Minor GC 和 Full GC 有什么不一样吗?

  • 新生代 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 倍以上。

当我们new一个对象后,会先放到Eden划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里JVM的处理是每个线程都会预先申请好一块连续的内存空间并规定了对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作我们会称作TLAB,有兴趣可以了解一下。

当Eden空间满了之后,会触发一个叫做Minor GC(就是一个发生在年轻代的GC)的操作,存活下来的对象移动到Survivor0区。Survivor0区满后触发 Minor GC,就会将存活对象移动到Survivor1区,此时还会把from和to两个指针交换,这样保证了一段时间内总有一个survivor区为空且to所指向的survivor区为空。经过多次的 Minor GC后仍然存活的对象(这里的存活判断是15次,对应到虚拟机参数为 -XX:TargetSurvivorRatio 。为什么是15,因为HotSpot会在对象投中的标记字段里记录年龄,分配到的空间仅有4位,所以最多只能记录到15)会移动到老年代。老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题。

而且当老年区执行了full gc之后仍然无法进行对象保存的操作,就会产生OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xms来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ya885crk-1613359072417)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214103112303.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VR5yuuEk-1613359072418)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214110234092.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2ZZfOZui-1613359072419)(/Users/wangzhanliang3/Library/Application Support/typora-user-images/image-20210214111047433.png)]

GC中Stop the world(STW)

垃圾回收首先是要经过标记的,对象被标记后就会根据不同的区域采用不同的收集方法。垃圾回收并不会阻塞我们程序的线程,他是与当前程序并发执行的。所以问题就出在这里,当GC线程标记好了一个对象的时候,此时我们程序的线程又将该对象重新加入了“关系网”中,当执行二次标记的时候,该对象也没有重写finalize()方法,因此回收的时候就会回收这个不该回收的对象。 虚拟机的解决方法就是****在一些特定指令位置设置一些“安全点”,当程序运行到这些“安全点”的时候就会暂停所有当前运行的线程****(Stop The World 所以叫STW),****暂停后再找到“GC Roots”进行关系的组建,进而执行标记和清除****。

这些特定的指令**(安全点)位置主要在**:

  • 循环的末尾
  • 方法临返回前 / 调用方法的call指令后
  • 可能抛异常的位置

停顿类型就是STW,至于有GC和Full GC之分,主要是*Full GC时STW的时间相对GC来说时间很长***,因为Full GC针对整个堆以及永久代的,因此整个GC的范围大大增加;还有就是他的回收算法就是“标记–清除–整理”,这里也会损耗一定的时间。所以我们*在优化JVM的时候,减少Full GC的次数也是经常用到的办法。*

各垃圾回收器的特点及区别,怎么做选择?

*如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。具体有Serial收集器(串行收集器)、ParNew收集器、Parallel Scavenge收集器、Serial Old 收集器、Parallel Old收集器、CMS收集器、G1收集器。*

下图中7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。重点分析 CMS 和 G1 这两款相对复杂的收集器,了解它们的部分运作细节。

img

*Serial收集器(串行收集器)*

Serial 收集器,一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。****“Stop The World”****这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。下图示意了 Serial/Serial Old 收集器的运行过程。

img

实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

*ParNew收集器*

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如下图所示。

img

ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器(并发收集器,后面有介绍)配合工作。

ParNew 收集器在单 CPU 的环境中不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。

当然,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(如 32 个)的环境下,可以使用**-XX:ParallelGCThreads**参数来限制垃圾收集的线程数。

注意,从 ParNew 收集器开始,后面还会接触到几款并发和并行的收集器。这里有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。

*Parallel Scavenge收集器*

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,****CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。****

所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即****吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)*,虚拟机总共运行了 100 分钟,其中垃圾收集花掉1分钟,那吞吐量就是99% 。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互*的任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及****直接设置吞吐量大小的-XX:GCTimeRatio参数****。

  • MaxGCPauseMillis参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • GCTimeRatio 参数的值应当是一个 0 到 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19,那允许的最大 GC 时间就占总时间的 5%(即 1/(1+19)),默认值为 99 ,就是允许最大 1%(即 1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数**-XX:+UseAdaptiveSizePolicy**值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。

*Serial Old 收集器*

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中详细讲解。Serial Old 收集器的工作过程如下图所示。

img

*Parallel Old收集器*

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是:如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge 收集器无法与 CMS 收集器配合工作)。

由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。

直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作过程如下图所示。

img

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。从名字(包含"Mark Sweep")上就可以看出,****CMS 收集器是基于“标记—清除”算法实现的****,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  • *初始标记(CMS initial mark)*
  • *并发标记(CMS concurrent mark)*
  • *重新标记(CMS remark)*
  • *并发清除(CMS concurrent sweep)*

其中,初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC RootsTracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

img

CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,但是 CMS 还远达不到完美的程度,它有以下 3 个明显的缺点:

  • 第一、导致吞吐量降低。

CMS 收集器对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU数量+3)/4,也就是当 CPU 在4个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如2个)时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。

  • 第二、CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次 Full GC(新生代和老年代同时回收) 的产生。

由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CM SInitiatingOccupancyFraction设置得太高很容易导致大量"Concurrent Mode Failure"失败,性能反而降低。

  • 第三、产生空间碎片。

CMS 是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC 。

为了解决这个问题,CMS 收集器提供了一个**-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction**,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

*G1收集器*

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,G1 是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。

img

与其他 GC 收集器相比,G1 具备如下特点:

  • 并行与并发: G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  • 分代收集: 与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
  • 空间整合: 与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC 。
  • 可预测的停顿: 这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region (不需要连续)的集合。

*G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集*。G1 在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是Garbage-First名称的来由),保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 *Remembered Set* 来避免全堆扫描的。

G1 中每个Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

G1 的前几个步骤的运作过程和 CMS 有很多相似之处:

  • 初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
  • 最后在筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,从Sun公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

G1和CMS的比较

  • CMS收集器是获取最短回收停顿时间为目标的收集器,因为CMS工作时,GC工作线程与用户线程可以并发执行,以此来达到降低停顿时间的目的(只有初始标记和重新标记会STW)。但是CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。
  • CMS仅作用于老年代,是基于标记清除算法,所以清理的过程中会有大量的空间碎片。
  • CMS收集器无法处理浮动垃圾,由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。
  • G1是一款面向服务端应用的垃圾收集器,适用于多核处理器、大内存容量的服务端系统。G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短STW的停顿时间,它满足短时间停顿的同时达到一个高的吞吐量。
  • 从JDK 9开始,G1成为默认的垃圾回收器。当应用有以下任何一种特性时非常适合用G1:Full GC持续时间太长或者太频繁;对象的创建速率和存活率变动很大;应用不希望停顿时间长(长于0.5s甚至1s)。
  • G1将空间划分成很多块(Region),然后他们各自进行回收。堆比较大的时候可以采用复制算法,碎片化问题不严重。****整体上看属于标记整理算法,局部(region之间)属于复制算法。****
  • G1 需要记忆集 (具体来说是卡表)来记录新生代和老年代之间的引用关系,这种数据结构在 G1 中需要占用大量的内存,可能达到整个堆内存容量的 20% 甚至更多。而且 G1 中维护记忆集的成本较高,带来了更高的执行负载,影响效率。所以 CMS 在小内存应用上的表现要优于 G1,而大内存应用上 G1 更有优势,大小内存的界限是6GB到8GB。

CMS垃圾回收器存在的问题及解决方案

CMS是使用标记-清理算法去垃圾回收的。其中四个主要的流程分别是****初始标记、并发标记、重新标记、并发清理****。

  • 并发消耗CPU资源

其中的并发标记和并发清理是工作线程和垃圾回收线程并发工作,这样在需要STW的时间内不会让整个系统不可用。但是在并发标记阶段,需要根据GC Roots标记出大量的存活对象,而在并发清理阶段,则需要将垃圾对象从各种随机内存位置删掉,这两个阶段都非常消耗性能,所以垃圾回收线程会占用一部分的CPU资源,导致系统的执行效率降低。

***CMS默认的回收线程数是 (CPU个数+3)/4,*当在*CPU核数较多的时候,对系统性能的影响并不是特别大*。但是如果是CPU核数较少,例如双核的时候,就会占用一个CPU去处理垃圾回收,系统的CPU资源直接降低50%,这就严重影响了效率

因为现在CPU的核数越来越多,所以这种场景基本不会对系统造成很大的影响,可以忽略不计。

  • Concurrent Mode Failure问题

*并发清理阶段****,工作线程和垃圾回收线程并发工作的时候,此时工作线程会不断产生新的垃圾,但是垃圾回收线程并不会去处理这些新生成的垃圾对象,需要等到下次垃圾回收的时候才会去处理,这些垃圾对象称之为:****浮动垃圾* 。因为有这些浮动垃圾的存在,所以老年代不能在100%使用的时候才去进行垃圾回收,否则就放不下这些浮动垃圾了。

****有一个参数是“-XX:CMSInitiatingOccupancyFraction”,*这个参数在jdk1.6里面默认是92%,意思是老年代使用了92%的空间就会执行垃圾回收了。但是即使*预留了8%的内存去存放浮动垃圾,但是还是有可能放不下****,这样就会产生Concurrent Mode Failure问题。一旦产生了Concurrent Mode Failure问题,系统会直接使用Serial Old垃圾回收器取代CMS垃圾回收器,从头开始进行GC Roots追踪对象,并清理垃圾,这样会导致整个垃圾回收的时间变得更长。

*解决办法就是根据系统的需求,合理设置“-XX:CMSInitiatingOccupancyFraction”的值,如果过大,则会产生Concurrent Mode Failure问题,如果设置的过小,则会导致老年代更加频繁的垃圾回收。*

  • 空间碎片问题

CMS的****标记-清理算法会在并发清理的阶段产生大量的内存碎片****,如果不整理的话,则会有大量不连续的内存空间存在,无法放入一些进入老年代的大对象,导致老年代频繁垃圾回收。所以****CMS存在一个默认的参数 “-XX:+UseCMSCompactAtFullCollection”****,意思是在Full GC之后再次STW,停止工作线程,整理内存空间,将存活的对象移到一边。还要一个****参数是“-XX:+CMSFullGCsBeforeCompaction”,****表示在进行多少次Full GC之后进行内存碎片整理,默认为0,即每次Full GC之后都进行内存碎片整理。

CMS虽然使用并发的方式降低了STW的时间,但是还需要配合一些CMS的参数才能完全发挥出CMS的优势,否则甚至会降低垃圾回收的效率。因此只有掌握了CMS的原理和参数的调试,才能让系统运行的更加流畅。

双亲委派模型

双亲委派的意思是如果一个类加载器需要加载类,那么首先它会把这个类请求委派给父类加载器去完成,每一层都是如此。一直递归到顶层,当父加载器无法完成这个请求时,子类才会尝试去加载。

双亲委派有啥好处:它使得类有了层次的划分。就拿java.lang.Object来说,你加载它经过一层层委托最终是由Bootstrap ClassLoader来加载的,也就是最终都是由Bootstrap ClassLoader去找<JAVA_HOME>\lib中rt.jar里面的java.lang.Object加载到JVM中。这样如果有不法分子自己造了个java.lang.Object,里面嵌了不好的代码,如果我们是按照双亲委派模型来实现的话,最终加载到JVM中的只会是我们rt.jar里面的东西,也就是这些核心的基础类代码得到了保护。因为这个机制使得系统中只会出现一个java.lang.Object,不会乱套了。你想想如果我们JVM里面有两个Object,那岂不是天下大乱了。

补充问题:

双亲委派模型的"破坏"

一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能“认识”这些代码那该怎么办?

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的 setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承 一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI服务使用这个线程上下 文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经 违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动 作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

JDBC和双亲委派模型关系

但是人生不如意事十之八九,有些情况不得不违反这个约束,例如JDBC。

先得知道SPI(Service Provider Interface),这玩意和API不一样,它是面向拓展的,也就是定义了这个SPI,具体如何实现由扩展者实现。

JDBC就是如此,在rt里面定义了这个SPI,那mysql有mysql的jdbc实现,oracle有oracle的jdbc实现,反正我java不管你内部如何实现的,反正你们都得统一按我这个来,这样我们java开发者才能容易的调用数据库操作。所以因为这样那就不得不违反这个约束啊,Bootstrap ClassLoader就得委托子类来加载数据库厂商们提供的具体实现。因为它的手只能摸到<JAVA_HOME>\lib中,其他的它无能为力,这就****违反了自下而上的委托机制****了。

*Java就搞了个线程上下文类加载器,通过setContextClassLoader()默认情况就是应用程序类加载器然后Thread.current.currentThread().getContextClassLoader()获得类加载器来加载。*

JVM锁优化和锁膨胀过程

高效并发是JDK 1.6的一个重要主题,HotSpot虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,如****适应性自旋(Adaptive Spinning)、锁削除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)等****,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

img

*自旋锁*

自选锁其实就是在拿锁时发现已经有线程拿了锁,自己如果去拿会阻塞自己,这个时候会选择进行一次忙循环尝试。也就是不停循环看是否能等到上个线程自己释放锁。这个问题是基于一个现实考量的:很多拿了锁的线程会很快释放锁。因为一般敏感的操作不会很多。当然这个是一个不能完全确定的情况,只能说总体上是一种优化。

基于这种做法的一个优化:自适应自旋锁。也就是说,第一次设置最多自旋10次,结果在自旋的过程中成功获得了锁,那么下一次就可以设置成最多自旋20次。
道理是:一个锁如果能够在自旋的过程中被释放说明很有可能下一次也会发生这种事。那么就更要给这个锁某种“便利”方便其不阻塞得锁(毕竟快了很多)。同样如果多次尝试的结果是完全不能自旋等到其释放锁,那么就说明很有可能这个临界区里面的操作比较耗时间。就减小自旋的次数,因为其可能性太小了。

锁粗化

原则上为了提高运行效率,锁的范围应该尽量小,减少同步的代码,但是这不是绝对的原则,试想有一个循环,循环里面是一些敏感操作,有的人就在循环里面写上了synchronized关键字。这样确实没错不过效率也许会很低,因为其频繁地拿锁释放锁。要知道锁的取得(假如只考虑重量级MutexLock)是需要操作系统调用的,从用户态进入内核态,开销很大。于是针对这种情况也许虚拟机发现了之后会适当扩大加锁的范围(所以叫锁粗化)以避免频繁的拿锁释放锁的过程。

比如像这样的代码:

synchronized{



做一些事情



}



synchronized{



做另外一些事情



}

就会被粗化成:

synchronized{



做一些事情



做另外一些事情



}

锁消除

通过逃逸分析发现其实根本就没有别的线程产生竞争的可能(别的线程没有临界量的引用),或者同步块内进行的是原子操作,而“自作多情”地给自己加上了锁。有可能虚拟机会直接去掉这个锁。

偏向锁

在大多数的情况下,锁不仅不存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking开启或者关闭

偏向锁的获取:偏向锁的获取过程非常简单,当一个线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,表示哪个线程获得了偏向锁,结合Mark Word来分析一下偏向锁的获取逻辑

  • 首先获取目标对象的Mark Word,根据锁的标识为和epoch去判断当前是否处于可偏向的状态
  • 如果为可偏向状态,则通过CAS操作将自己的线程ID写入到MarkWord,如果CAS操作成功,则表示当前线程成功获取到偏向锁,继续执行同步代码块
  • 如果是已偏向状态,先检测MarkWord中存储的threadID和当前访问的线程的threadID是否相等,如果相等,表示当前线程已经获得了偏向锁,则不需要再获得锁直接执行同步代码;如果不相等,则证明当前锁偏向于其他线程,需要撤销偏向锁。

偏向锁的撤销:当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,撤销偏向锁的过程需要等待一个全局安全点(所有工作线程都停止字节码的执行)。

  • 首先,暂停拥有偏向锁的线程,然后检查偏向锁的线程是否为存活状态
  • 如果线程已经死了,直接把对象头设置为无锁状态
  • 如果还活着,当达到全局安全点时获得偏向锁的线程会被挂起,接着偏向锁升级为轻量级锁,然后唤醒被阻塞在全局安全点的线程继续往下执行同步代码

轻量级锁

当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销。当前线程会尝试使用CAS来获取锁,当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁。

当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销。偏向锁撤销以后对象会可能会处于两种状态

  • 一种是不可偏向的无锁状态,简单来说就是已经获得偏向锁的线程已经退出了同步代码块,那么这个时候会撤销偏向锁,并升级为轻量级锁
  • 一种是不可偏向的已锁状态,简单来说就是已经获得偏向锁的线程正在执行同步代码块,那么这个时候会升级到轻量级锁并且被原持有锁的线程获得锁

那么升级到轻量级锁以后的加锁过程和解锁过程是怎么样的呢?

轻量级锁加锁

  • JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(LockRecord)
  • 将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word.
  • 线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针
  • 如果替换成功,表示当前线程获得轻量级锁,如果失败,表示存在其他线程竞争锁,那么当前线程会尝试使用CAS来获取锁, 当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁

轻量级锁解锁

  • 尝试CAS操作将所记录中的Mark Word替换回到对象头中
  • 如果成功,表示没有竞争发生
  • 如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁

*重量级锁*

*重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁)*

大家如果对MutexLock有兴趣,可以抽时间去了解,假设Mutex变量的值为1,表示互斥锁空闲,这个时候某个线程调用lock可以获得锁,而Mutex的值为0表示互斥锁已经被其他线程获得,其他线程调用lock只能挂起等待

为什么重量级锁的开销比较大呢?
原因是当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的

*锁的膨胀过程*

首先简单说下先偏向锁、轻量级锁、重量级锁三者各自的应用场景:

  • 偏向锁: 只有一个线程进入临界区;
  • 轻量级锁: 多个线程交替进入临界区;
  • 重量级锁: 多个线程同时进入临界区。

首先它们的关系是:****最高效的是偏向锁,尽量使用偏向锁,如果不能(发生了竞争)就膨胀为轻量级锁,最后是重量级锁。****

JVM中GC Root的选择标准是什么?相关JVM的调优参数有哪些?在工作中怎么调优的?

  • 可作为GC Roots的对象包括:虚拟机栈(栈帧局部变量)中引用的对象、方法区类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象
  • HotSpot 使用了一组叫做 OopMap 的数据结构达到准确式GC的目的
  • 在OopMap的协助下,JVM可以很快的做完GC Roots 枚举。但是JVM并没有为每一条指令生成一个OopMap
  • 记录OopMap 的这些“特定位置”被称为安全点,即当前线程执行到安全点后才允许暂停进行GC

*在Java语言中,可作为 GC Roots 的对象包括下面几种:*

  • a. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • b. 方法区中类静态属性引用的对象。
  • c. 方法区中常量引用的对象。
  • d. 本地方法栈中 JNI(Native方法)引用的对象

作为 GC Roots 的节点主要在全局性的引用与执行上下文中。要明确的是,tracing gc必须以当前存活的对象集为 Roots,因此必须选取确定存活的引用类型对象。

JVM常见的调优参数包括:

  • -Xmx:指定java程序的最大堆内存, 使用java -Xmx5000M -version判断当前系统能分配的最大堆内存
  • -Xms:指定最小堆内存, 通常设置成跟最大堆内存一样,减少GC
  • -Xmn:设置年轻代大小。整个堆大小=年轻代大小 + 年老代大小。所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
  • -Xss:指定线程的最大栈空间,此参数决定了java函数调用的深度,,值越大调用深度越深,,若值太小则容易出栈溢出错误(StackOverflowError)
  • -XX:PermSize:指定方法区(永久区)的初始值,默认是物理内存的1/64, 在Java8永久区移除, 代之的是元数据区, 由-XX:MetaspaceSize指定
  • -XX:MaxPermSize:指定方法区的最大值, 默认是物理内存的1/4, 在java8中由-XX:MaxMetaspaceSize指定元数据区的大小
  • -XX:NewRatio=n:年老代与年轻代的比值,-XX:NewRatio=2, 表示年老代与年轻代的比值为2:1
  • -XX:SurvivorRatio=n:Eden区与Survivor区的大小比值,-XX:SurvivorRatio=8表示Eden区与Survivor区的大小比值是8:1:1,因为Survivor区有两个(from, to)

参考(https://blog.csdn.net/qq_46153765/article/details/113092445?ops_request_misc=%25257B%252522request%25255Fid%252522%25253A%252522161285986416780269888151%252522%25252C%252522scm%252522%25253A%25252220140713.130102334…%252522%25257D&request_id=161285986416780269888151&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allsobaiduend~default-4-113092445.first_rank_v2_pc_rank_v29&utm_term=JVM)

Stop-The-World

  • STW是Java中一种全局暂停的现象,多半由于GC引起。所谓全局停顿,就是所有Java代码停止运行,native代码可以执行,但不能和JVM交互
  • 其危害是长时间服务停止,没有响应;对于HA系统,可能引起主备切换,严重危害生产环境

垃圾收集类型

  • 串行收集:GC单线程内存回收、会暂停所有的用户线程,如:Serial
  • 并行收集:多个GC线程并发工作,此时用户线程是暂停的,如:Parallel
  • 并发收集:用户线程和GC线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程,如:CMS

判断类无用的条件

  • JVM 中该类的所有实例都已经被回收
  • 加载该类的 ClassLoader 已经被回收
  • 没有任何地方引用该类的 Class 对象
  • 无法在任何地方通过反射访问这个类

垃圾收集器

  • 串行收集器、并行收集器、新生代Parallel、Scavenge收集器、CMS、G1

  • 在这里插入图片描述

  • 使用 -XX:+UseSerialGC 来开启,会使用:Serial + SerialOld 的收集器组合

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

并行收集器

ParNew收集器
  • ParNew(并行)收集器:使用多线程进行垃圾回收,在垃圾收集时,会Stop-the-World
  • 在这里插入图片描述
  • 在并发能力好的 CPU 环境里,它停顿的时间要比串行收集器短;但对于单 CPU 或并发能力较弱的CPU,由于多线程的交互开销,可能比串行回收器更差
  • 是 Server 模式下首选的新生代收集器,且能和 CMS 收集器配合使用
  • 不再使用 -XX:+UseParNewGC来单独开启
  • -XX:ParallelGCThreads:指定线程数,最好与 cpu 数量一致
新生代Parallel Scavenge 收集器
  • 新生代 Parallel Scavenge 收集器 / Parallel Old 收集器:是一个应用于新生代的,使用复制算法的、并行的收集器
  • 与 ParNew 很类似,但更关注吞吐量,能最高效率的利用 CPU,适合运行后台应用
  • 在这里插入图片描述
  • 使用 -XX:+UseParallelGC 来开启
  • 使用 -XX:+UseParallelOldGC 来开启老年代使用 ParallelOld收集器,使用 Parallel Scavenge + Parallel Old 的收集器组合
  • -XX:MaxGCPauseMillis:设置GC 的最大停顿时间
  • 新生代使用复制算法,老年代使用标记-整理算法

CMS收集器

  • CMS(Concurrent Mark and Sweep 并发标记清除)收集器分为:初始标记:只标记GC Roots 能直接关联到的对象;并发标记:进行GC Roots Tracing 的过程
  • 重新标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
  • 并发清除:并发回收垃圾对象
  • 在这里插入图片描述
  • 在初始化标记和重新标记两个阶段还是会发生 Stop-the-World
  • 使用标记清除算法,多线程并发收集的垃圾收集器
  • 最后的重置线程,指的是清空跟收集相关的数据并重置,为下次收集做准备
  • 优点:低停顿,并发执行
  • 缺点:
    • 并发执行,对 CPU 资源压力大
    • 无法处理 在处理过程中 产生的垃圾(浮动垃圾),可能导致 FullGC
    • 采用的标记清除算法会导致大量碎片,从而在分配大对象可能触发 FullGC
  • 开启:-XX:UseConcMarkSweepGC:使用 ParNew + CMS + Serial Old 的收集器组合,Serial Old 将作为 CMS 出错的后备收集器
  • -XX:CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发回收,默认 80%

G1收集器

  • G1(Garbage-First)收集器:是一款面向服务应用的收集器,与其他收集器相比,具有以下特点:
    1. G1 把内存划分成多个独立的区域(Region)
    2. G1 仍采用分代思想,保留了新生代和老年代,但它们不再是物理隔离的,而是一部分Region的集合,且不需要 Region 是连续的
  • 在这里插入图片描述
  • G1 能充分利用多 CPU 、多核环境硬件优势,尽量缩短 STW
  • G1 整体上采用标记-整理算法,局部是通过复制算法,不会产生内存碎片
  • G1 的停顿可预测,能明确指定在一个时间段内,消耗在垃圾收集上的时间不能超过多长时间
  • G1 跟踪各个 Region 里面垃圾堆的价值大小,在后台维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证在有限时间内的高效收集
  • 垃圾收集:
    • 初始标记:只标记GC Roots 能直接关联到的对象
    • 并发标记:进行 GC Roots Tracing 的过程
    • 最终标记:修正并发标记期间,因程序运行导致标记发生变化的那一部分对象
    • 筛选回收:根据时间来进行价值最大化的回收
  • 在这里插入图片描述
  • 使用和配置G1:-XX:+UseG1GC:开启G1,默认就是G1
  • -XX:MaxGCPauseMillis = n :最大GC停顿时间,这是个软目标,JVM将尽可能(但不保证)停顿小于这个时间
  • -XX:InitiatingHeapOccupancyPercent = n:堆占用了多少的时候就触发GC,默认为45
  • -XX:NewRatio = n:默认为2
  • -XX:SurvivorRatio = n:默认为8
  • -XX:MaxTenuringThreshold = n:新生代到老年代岁数,默认是15
  • -XX:ParallelGCThreads = n:并行GC的线程数,默认值会根据平台不同而不同
  • -XX:ConcGCThreads = n:并发 GC 使用的线程数
  • -XX:G1ReservePercent = n:设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值是 10%
  • -XX:G1HeapRegionSize = n:设置的 G1 区域的大小。值是2的幂,范围是1MB到32MB,目标是根据最小的Java堆大小划分出约2048个区域

高效并发

Java内存模型和内存间的交互操作

Java内存模型

  • JCP 定义了一种 Java 内存模型,以前是在 JVM 规范中,后来独立出来成为JSR-133(Java内存模型和线程规范修订)
  • 内存模型:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象
  • Java 内存模型主要关注 JVM 中把变量值存储到内存和从内存中取出变量值这样的底层细节
  • 在这里插入图片描述
  • 所有变量(共享的)都存储在主内存中,每个线程都有自己的工作内存;工作内存中保存该线程使用到的变量的主内存副本拷贝
  • 线程对变量的所有操作(读、写)都应该在工作内存中完成
  • 不同线程不能相互访问工作内存,交互数据要通过主内存

内存间的交互操作

  • Java内存模型规定了一些操作来实现内存间交互,JVM会保存它们是原子的
  • lock:锁定,把变量标识为线程独占,作用于主内存变量
  • unlock:解锁,把锁定的变量释放,别的线程才能使用,作用于主内存变量
  • read:读取,把变量从主内存读取到工作内存
  • load:载入,把read读取到的值放入工作内存的变量副本中
  • use:使用,把工作内存中一个变量的值传递给执行引擎
  • assign:赋值,把从执行引擎接收到的值赋给工作内存里面的变量
  • store:存储,把工作内存中一个变量的值传递到主内存中
  • wirte:写入,把 store 进来的数据存放如主内存的变量中
  • 在这里插入图片描述

内存间的交互操作的规则

  • 不允许 read 和 load 、store 和 write 操作之一单独出现,以上两个操作必须按照顺序执行,但不保证连续执行,也就是说,read 和 load 之间、store 与 write 之间是可插入其他指令的
  • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能从主内存中 ”诞生“,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作
  • 一个变量在同一个时刻只允许一条线程对其执行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的 unlock 操作,变量才会被解锁
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值
  • 如果一个变量没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不能 unlock 一个被其他线程锁定的变量
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存(执行 store 和 write 操作)

volatile特性

多线程中的可见性

  • 可见性:就是一个线程修改了变量,其他线程可以知道
  • 保证可见性的常见方法:volatile、synchronized、final(一旦初始化完成,其他线程就可见)

volatile

  • volatile 基本上是 JVM 提供的最轻量级的同步机制,用 volatile 修饰的变量,对所有的线程可见,即对 volatile 变量所做的写操作能立即反映到其他线程中

  • 用 volatile 修饰的变量,在多线程环境下仍然是不安全的

  • volatile 修饰的变量,是禁止指令重排优化的

  • 适合使用 valatile 的场景

    • 运算结果不依赖变量的当前值
    • 确保只有一个线程修改变量的值

指令重排

  • 指令重排:指的是 JVM 为了优化,在条件允许的情况下,对指令进行一定的重新排列,直接运行当前能够立即执行的后序指令,避开获取下一条指令所需数据造成的等待

  • 线程内串行语义,不考虑多线程间的语义

  • 不是所有的指令都能重排,比如:

    • 写后读 a = 1; b = a;写一个变量之后,再读这个位置
    • 写后写 a = 1;a = 2;写一个变量之后,再写这个变量
    • 读后写 a = b;b = 1;读一个变量之后,再写这个变量
  • 以上语句不可重排,但是 a = 1;b = 2;是可以重排的

  • 程序顺序原则:一个线程内保证语义的串行性

  • volatile规则:volatile 变量的写,先发生于读

  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前

  • 传递性:A 先于 B,B 先于 C,那么 A 必然先于 C

  • 线程的 start 方法先于它的每一个动作

  • 线程的所有操作先于线程的终结

  • 线程中断(interrupt())先于被中断线程的代码

  • 对象的构造函数执行结束先于 finalize() 方法

Java线程安全的处理方法

  • 不可变是线程安全的

  • 互斥同步(阻塞同步):synchronized、java.util.concurrent.ReentrantLock。目前这两个方法性能已经差不多了,建议优先选用 synchronized,ReentrantLock 增加了如下特性:

    • 等待可中断:当持有锁的线程长时间不释放锁,正在等待的线程可以选择放弃等待
    • 公平锁:多个线程等待同一个锁时,须严格按照申请锁的时间顺序来获取锁
    • 锁绑定多个条件:一个 ReentrantLock 对象可以绑定多个 condition 对象,而 synchronized 是针对一个条件的,如果要多个,就得有多个锁
  • 非阻塞同步:是一种基于冲突检查的乐观锁策略,通常是先操作,如果没有冲突,操作就成功了,有冲突再采取其他方式进行补偿处理

  • 无同步方案:其实就是在多线程中,方法并不涉及共享数据,自然也就无需同步了

锁优化

自旋锁与自适应自旋

  • 自旋:如果线程可以很快获得锁,那么可以不再 OS 层挂起线程,而是让线程做几个忙循环,这就是自旋
  • 自适应自旋:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者状态来决定
  • 如果锁被占用时间很短,自旋成功,那么能节省线程挂起、以及切换时间,从而提升系统性能
  • 如果锁被占用时间很长,自旋失败,会白白浪费处理器资源,降低系统性能

锁消除

  • 在编译代码的时候,检测到根本不存在共享数据竞争,自然也就无需同步加锁了;通过 -XX:+EliminateLocks 来开启

  • 同时要使用 -XX:DoEscapeAnalysis 开启逃逸分析

    逃逸分析:

    1. 如果一个方法中定义的一个对象,可能被外部方法引用,称为方法逃逸
    2. 如果对象可能被其他外部线程访问,称为线程逃逸,比如赋值给类变量或者可以在其他线程中访问的实例变量

锁粗化

  • 通常我们都要求同步块要小,但一系列连续的操作导致一个对象反复的加锁和解锁,这会导致不必要的性能损耗。这种情况建议把锁同步的范围加大到整个操作序列

轻量级锁

  • 轻量级是相对于传统锁机制而言,本意是没有多线程竞争的情况下,减少传统锁机制使用 OS 实现互斥所产生的性能损耗
  • 其实现原理很简单,就是类似乐观锁的方式
  • 如果轻量级锁失败,表示存在竞争,升级为重量级锁,导致性能下降

偏向锁

  • 偏向锁是在无竞争情况下,直接把整个同步消除了,连乐观锁都不用,从而提高性能;所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程
  • 只要没有竞争,获得偏向锁的线程,在将来进入同步块,也不需要做同步
  • 当有其他线程请求相同的锁时,偏向模式结束
  • 如果程序中大多数锁总是被多个线程访问的时候,也就是竞争比较激烈,偏向锁反而会降低性能
  • 使用 -XX:-UseBiasedLocking 来禁用偏向锁,默认开启

JVM 中获取锁的步骤

  • 会先尝试偏向锁;然后尝试轻量级锁
  • 再然后尝试自旋锁
  • 最后尝试普通锁,使用 OS 互斥量在操作系统层挂起

同步代码的基本规则

  • 尽量减少持有锁的时间
  • 尽量减少锁的粒度

JVM性能监控有哪些?

JDK的命令行工具

  • jps(虚拟机进程状况工具):jps可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称 以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)。
  • jstat(虚拟机统计信息监视工具):jstat是用于监视虚拟机各种运行状态信息的命令行工 具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
  • jinfo(Java配置信息工具):jinfo的作用是实时地查看和调整虚拟机各项参数。
  • jmap(Java内存映像工具):命令用于生成堆转储快照(一般称为heapdump或dump文 件)。如果不使用jmap命令,要想获取Java堆转储快照,还有一些比较“暴力”的手段:譬如 在第2章中用过的-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在OOM异常出 现之后自动生成dump文件。jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永 久代的详细信息,如空间使用率、当前用的是哪种收集器等。
  • jhat(虚拟机堆转储快照分析工具):jhat命令与jmap搭配使用,来分析jmap生成的堆 转储快照。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,可以在 浏览器中查看。
  • jstack(Java堆栈跟踪工具):jstack命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈 的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循 环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿 的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些 什么事情,或者等待着什么资源。

JDK的可视化工具

  • JConsole
  • VisualVM

Java管理内存、内存泄漏和泄漏的原因?

*Java是如何管理内存*

Java的内存管理就是对象的分配和释放问题。

在 Java 中,程序员需要通过关键字 new 为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。另外,对象的释放是由 GC 决定和执行的。在 Java 中,内存的分配是由程序完成的,而内存的释放是由 GC 完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是 Java 程序运行速度较慢的原因之一。因为,GC 为了能够正确释放对象,GC 必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC 都需要进行监控。监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。

为了更好理解 GC 的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用者指向被引对象。另外,每个线程对象可以作为一个图的起始顶点,例如大多程序从 main 进程开始执行,那么该图就是以 main 进程顶点开始的一棵根树。在这个有向图中,根顶点可达的对象都是有效对象,GC将不回收这些对象。如果某个对象 (连通子图)与这个根顶点不可达(注意,该图为有向图),那么我们认为这个(这些)对象不再被引用,可以被 GC 回收。 以下,我们举一个例子说明如何用有向图表示内存管理。对于程序的每一个时刻,我们都有一个有向图表示JVM的内存分配情况。以下右图,就是左边程序运行到第6行的示意图。

img

Java使用有向图的方式进行内存管理,可以消除引用循环的问题,例如有三个对象,相互引用,只要它们和根进程不可达的,那么GC也是可以回收它们的。这种方式的优点是管理内存的精度很高,但是效率较低。另外一种常用的内存管理技术是使用计数器,例如COM模型采用计数器方式管理构件,它与有向图相比,精度行低(很难处理循环引用的问题),但执行效率很高。

*什么是Java中的内存泄露*

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

在C++中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来。在Java中,这些不可达的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。

通过分析,我们得知,对于C++,程序员需要自己管理边和顶点,而对于Java程序员只需要管理边就可以了(不需要管理顶点的释放)。通过这种方式,Java提高了编程的效率。

img

因此,通过以上分析,我们知道在Java中也有内存泄漏,但范围比C++要小一些。因为Java从语言上保证,任何对象都是可达的,所有的不可达对象都由GC管理。

对于程序员来说,GC基本是透明的,不可见的。虽然,我们只有几个函数可以访问GC,例如运行GC的函数System.gc(),但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低。JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。

*Java内存泄漏引起的原因*

内存泄漏是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费称为内存泄漏。内存泄露有时不严重且不易察觉,这样开发者就不知道存在内存泄露,但有时也会很严重,会提示你Out of memory。

Java内存泄漏的根本原因是什么呢?长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄漏,尽管短生命周期对象已经不再需要,但是因为长生命周期持有它的引用而导致不能被回收,这就是Java中内存泄漏的发生场景。具体主要有如下几大类:

  • 静态集合类引起内存泄漏

像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。

Static Vector v = new Vector(10);



for (int i = 1; i<100; i++)



{



Object o = new Object();



v.add(o);



o = null;



}

在这个例子中,循环申请Object 对象,并将所申请的对象放入一个Vector 中,如果仅仅释放引用本身(o=null),那么Vector 仍然引用该对象,所以这个对象对GC 来说是不可回收的。因此,****如果对象加入到Vector 后,还必须从Vector 中删除,最简单的方法就是将Vector对象设置为null。****

  • 当集合里面的对象属性被修改后,再调用remove()方法时不起作用。
public static void main(String[] args) {



Set<Person> set = new HashSet<Person>();



Person p1 = new Person("唐僧","pwd1",25);



Person p2 = new Person("孙悟空","pwd2",26);



Person p3 = new Person("猪八戒","pwd3",27);



set.add(p1);



set.add(p2);



set.add(p3);



System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:3 个元素!



p3.setAge(2); //修改p3的年龄,此时p3元素对应的hashcode值发生改变



set.remove(p3); //此时remove不掉,造成内存泄漏



set.add(p3); //重新添加,居然添加成功



System.out.println("总共有:"+set.size()+" 个元素!"); //结果:总共有:4 个元素!



for (Person person : set) {



System.out.println(person);



}



}
  • 监听器

在java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如addXXXListener()等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。

  • 各种连接

比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。对于Resultset 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,因为Connection 在任何时候都无法自动回收,而Connection一旦回收,Resultset 和Statement 对象就会立即为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。

  • 内部类和外部模块的引用

内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。此外程序员还要小心外部模块不经意的引用,例如程序员A 负责A 模块,调用了B 模块的一个方法如:

public void registerMsg(Object b);

这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B 是否提供相应的操作去除引用。

  • 单例模式

不正确使用单例模式是引起内存泄漏的一个常见问题,单例对象在初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被JVM正常回收,导致内存泄漏,考虑下面的例子:

class A {



public A() {



B.getInstance().setA(this);



}



....



}



//B类采用单例模式



class B {



private A a;



private static B instance=new B();



public B(){}



public static B getInstance() {



    return instance;



}



public void setA(A a){



    this.a=a;



}



//getter...



}

显然B采用singleton模式,它持有一个A对象的引用,而这个A类的对象将不能被回收。想象下如果A是个比较复杂的对象或者集合类型会发生什么情况。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值