类加载器子系统:从文件系统或网络中加载class文件,class文件在文件开头有特定的文件标识 CAFEBABE
类加载器加载的类信息,会放在方法区的内存空间。
1.1、类加载的过程加载阶段:
1、通过类的全限定类名获取此类的二进制流
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
链接阶段
- 验证(Verify):
- 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,确保被加载类的正确性。不会危害虚拟机自身安全
- 四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
- 准备
- 为类变量分配内存并设置该类的默认初始化值。
- 不包含用final修饰的static ,因为final在编译的时候就会分配了,准备阶段会显示初始化;
- 不会为实例变量分配初始化,类变量会分配在方法区,实例变量会随着对象一起分配到Java堆中。
- 解析
- 将常量池内的符号引用转换为直接引用的过程
- 事实上,解析 *** 作往往会伴随着JVM在执行完初始化之后再执行。
- 符号引用就是一组符号来描述所引用的目标。直接引用就是指向目标的指针、相对偏移量或者一个间接定位到目标的句柄
- 解析动作主要针对 类或接口、字段、类方法、方法类型等
- 个人理解:比如执行run方法,符号引用就类似用类的全限定名+run方法标识表示,而直接引用就是引用run方法分配到的地址
初始化阶段
- 初始化阶段就是执行类构造方法的过程
- 此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来
- 构造器方法中指令是按照语句在文件中出现的顺序执行
( )不同于类的构造器。(注意:类构造器是虚拟机视角下的 ()) - 若该类有super类,JVM会保证子类的
()执行前,super类的已经执行完毕 - 虚拟机必须保证一个类的
方法在多线程下被同步加锁
总的来说类加载器分为两大类:引导类加载器,用户自定义类加载器
如图所示:
在图中除了Bootstrap Class Loader以外,其他的类加载器都直接或间接的继承ClassLoader
(启动类加载器 Bootstrap Class Loader)
- c/c++实现,嵌套在JVM内部
- 用来加载JAVA核心库(jre/lib/rt.jar 、resource.jar 或者sun.boot.class.path路径下的内容,用于提供JVM自身需要的类)
- 没有继承 ClassLoader
- 加载 扩展类和应用程序类加载器,并指定他们的父类加载器
- 只加载java、javax、sun开头的类
(Extension ClassLoader)
- java编写的
- 派生于ClassLoader类
- 是由引导类加载器 加载的
- 从java.ext.dirs系统属性所制定的目录下加载类库,或者从JDK的安装目录jre/lib/ext子目录下加载类库。用户创建的JAR放在此位置,也会自动由扩展类加载器加载
(系统类加载器 Application ClassLoader)
- java编写
- 派生于ClassLoader
- 由扩展类加载器 加载
- 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
- 类加载程序中默认的类加载器
- 通过ClassLoader.getSystemClassLoader()方法可以获取该类加载器
为什么要自定义类加载器?
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
自定义类加载器实现步骤
1、可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求
2、在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载器,但是在JDK1.2之后,不建议覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中
3、在编写自定义类加载器时,如果没有太复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式。
获取ClassLoader的方法
1、class.getClassLoader() :获取当前类的类加载器
2、Thread.currentThread().getContextClassLoader(); 获取当前线程上下文的ClassLoader
3、ClassLoader.getSystemClassLoader();获取当前系统的ClassLoader
4、DriverManager.getCallerClassLoader() 获取调用者的ClassLoager000
1、如果一个类加载器收到了类加载得请求,他并不会自己去加载,而是把这个请求委托给父类加载器去执行
2、如果父类加载器还存在父类加载器,则进一步向上委托,依次递归请求,最终将到达引导类加载器
3、如果父类加载器可以完成加载任务,就成功返回,若父类加载器无法完成加载任务,子类加载器才会尝试自己去加载
==注意:==这里说的父类加载器,指的是加载该类加载器的类加载器
优势
1、避免类的重复加载
2、保护程序安全,防止核心api被篡改。
自定义String类,但是加载自定义String类时会先用启动类加载器加载,而启动类加载器会先加载jdk自带的文件(rt.jar包中javalangString.class),报错没有main方法,就是因为加载的是rt.jar包下的String类,这样可以保证对java核心源代码的保护。
类的使用方式分为主动使用和被动使用
主动使用
1、创建类的实例
2、访问某个类或借口的静态变量,或者对静态变量赋值
3、调用类的静态方法
4、反射
5、初始化一个类的子类
6、Java虚拟机启动时被标明为启动类的类
7、JDK7开始提供的动态语言支持
其他的使用JAVA类的方式都是类的被动使用,都不会进行类的初始化
Java虚拟机定义了部分程序运行期间会使用运行时数据区,其中一部分随虚拟机的创建销毁而创建销毁,一部分是跟线程对应的
运行时数据区内存模型
(program Counter Register) 程序计数寄存器 又称PC寄存器
作用:PC寄存器用来存储当前线程指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
- 是一块较小的内存空间
- 每一条Java虚拟机线程都有自己的pc寄存器。
- 可看作当前线程所执行的字节码的行号指示器
- 任意时刻,一条Java虚拟机线程只会运行一个方法,该方法被称为当前方法
- 若该方法不是native的,那pc寄存器就保存Java虚拟机正在执行的字节码指令的地址
若是native的,那么pc寄存器的值是undefined。
- pc寄存器的容量,至少可以存一个returnAddress类型的数据或者一个与平台相关的
本地指针的值
- 此区域是唯一一个在Java虚拟机规范中没有规定任何OurOfMemoryError情况的区域
Java虚拟机栈是什么?
java虚拟机栈(Java Virtual Machine Stack),早期也叫java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个栈帧(Stack frame),对应着一次次的Java方法调用。
一个栈帧对应着一个方法
生命周期
随着线程的创建而创建,消失而消失。
作用
主管Java程序的运行,他保存方法的局部变量、部分结果、并参与方法的调用和返回。
异常
Java虚拟机规范允许栈的大小是动态的或者固定不变的
- 如果线程请求的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机抛出StackOverflowError异常
- 如果Java虚拟机栈可以动态扩展,在尝试扩展时无法申请足够内存,或创建新的线程时没有足够内存去创建对应的虚拟机栈,那么Java虚拟机将会抛出OutOfMemoryError异常
每个线程都有私有的栈,栈中的数据都是以栈帧(Stack frame)的格式存在的
在线程中正在执行的每个方法都各自对应一个栈帧(frame)
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中各种数据信息
注意:不同线程中所包含的栈帧是不允许存在相互引用的,既不可能 在一个栈帧之中引用另外一个线程的栈帧。
Java方法的两种返回函数的方式
一种是通过return指令进行正常的函数返回,另外一种是抛出异常,不管使用哪种方式,都会导致栈帧被d出。
在栈帧中存储着:
- 局部变量表(Local Variables)
- *** 作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或异常退出的定义)
- 一些附加信息
- 定义为一个数组,用于存储方法参数和定义在方法体内的局部变量,包括基本数据类型、对象引用、returnAddress类型
- 局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
- 局部变量表的大小是在编译期确定下来的。方法运行期间不会改变局部变量表的大小
- 局部变量表随着方法栈帧的销毁而销毁
局部变量表的基本存储单位:Slot(变量槽)
在局部变量表里,32位以内的类型只占用一个Slot(包括returnAddress类型),64位的类型(long或double)占用两个Slot。
非静态方法栈帧的局部变量表中索引为零的位置上会多一个this(对当前对象的引用)
局部变量与成员变量在赋值时的区别
变量的分类:按照数据类型分:1、基本数据类型;2、引用数据类型
按照在类中声明的位置分:1、成员变量(类变量,实例变量);2、局部变量
成员变量:在使用前,都默认的初始化赋值
类变量 :lingking的prepare阶段:给类变量默认赋值 —> inital阶段:给类变量显示赋值;
实例变量:随着对象的创建,在堆空间中分配实例变量空间,并进行默认赋值
局部变量:在使用前,必须显示赋值,否则编译不通过。
在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,及入栈(push)、出栈(pop)。
主要保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
*** 作数栈的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。
在 *** 作数栈里,32位以内的类型只占用一个栈单位深度,64位的类型(long或double)占用两个栈单位深度。
通过i++和++i理解 *** 作数栈
指向运行时常量池的方法引用
每个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了就是为了支持当前方法的代码能够实现动态链接。
动态链接的作用
4、方法的调用在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用被保存在class文件的常量池中。
描述一个方法调用另外其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接就是为了将这些符号引用转换为调用方法的直接引用。
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
(以下内容理解时想想多态)
静态链接:
当一个字节码文件被转载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接
如果被调用的方法在编译期无法被确定下来,即只能在程序运行期将调用方法的符号引用转换为直接引用。由于这种引用转换的过程具备动态性,因此也就被称之为动态链接
方法的绑定机制
对应的方法的绑定机制:早期绑定和晚期绑定。绑定是一个字段,方法或者类在符号引用被替换为直接引用弄个的过程,这仅仅发生一次
虚方法和非虚方法
- 普通调用指令
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法,私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- 动态调用指令
- invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法
方法重写的本质
1、找到 *** 作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
2、如果在类型C中找到与常量池中的描述符和简单名称都符合的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3、否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
4、如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
虚方法表
在面向对象的编程中,会频繁的使用动态分派,如果每次动态分派的过程都要重新在类的方法元数据中搜索合适的目标的话就会影响到执行效率。
为了提高性能,JVM采用在类的方法区建立一个虚方法表来实现。使用索引表来替代查找。
每个类都有一个虚方法表,表中存放着各种方法的实际入口。
虚方法表的创建时间
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
5、方法返回地址存放调用该方法的pc寄存器的值。
方法的结束方式
1、正常执行完成
2、出现未处理的异常,非正常退出
正常退出,调用者的PC寄存器的值作为返回地址,
异常退出,返回地址是通过异常表来确定的,栈帧中不会存储这部分信息
栈帧中还允许携带一些与Java虚拟机实现相关的一些附加信息。不一定有
2.3、本地方法栈Native Method 是一个Java调用非Java代码的接口。
- 用于支持native方法(指使用java以外的其他语言写的方法)的执行。
- 如果虚拟机支持该栈,则该栈在线程创建时创建
- 可固定大小,也可根据计算动态扩展和收缩
- 可能的异常与Java虚拟机栈一样
当某个线程调用一个本地方法时,他就不在受虚拟机限制,和虚拟机有相同的权限
2.4、堆 2.4.1、概述- 一个JVM实例只存在一个堆内存,在JVM启动的时候即被创建,其空间大小也被确定。是JVM管理的最大的一块内存空间
- 堆内存大小可调节
- 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上视为连续的
- 所有线程都共享Java堆,还可以划分线程私有的缓冲区。
- 《Java虚拟机规范》描述:所有的对象实例以及数组都应当在运行时分配在堆上。
- “几乎”所有的对象实例都在这里分配内存。
- 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
- 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
栈、堆、方法区的联系
现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
- JDK7及之前:堆内存逻辑上分为新生区,养老区,永久区。
- JDK8及以后:堆内存逻辑上分为新生区,养老区,元空间。
约定:
新生区<=>新生代<=>年轻代;
养老区<=>老年区<=>老年代
永久区<=>永久代
堆内存结构示意图
可以通过-Xmx,-Xms来进行设置堆空间大小
- -Xms:用于表示堆区的起始内存,等价于-XX:InitialHeapSize
- -Xmx:用于表示堆区的最大内存,等价于-XX:MaxHeapSize
一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常
通常将 -Xms和Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分割计算堆区的大小,从而提高性能
默认情况下,初始内存大小:物理内存大小的 1/64
最大内存大小:物理内存大小的 1/4
查看设置的参数:
方式一:cmd中jps, jstat -gc 进程id
方式二:加参数-XX:+PrintGCDeTails
存储在JVM中的Java对象分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常快
- 一类是生命周期非常长的对象
堆可细分为年轻代、老年代
年轻代又可以划分为 Eden空间,Survivor0(from区),Survivor1空间(to区)
配置新生代与老年代在堆结构的占比
默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆得1/3
修改为-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆得1/5
设置新生代中Eden区与Survivor区比例
HotSpot中,新生代的Eden空间和另外两个Survivor空间所占比例,默认为8:1:1(官网)
但是事实上通过JVisualVM发现,并不是8:1:1,而是6:1:1;这是因为开启了自适应的内存分配策略。
可以通过-XX:SurvivorRatio=8来调整这个空间比例为8:1:1。
几乎所有的对象都是从“Eden”区new出来的,绝大部分的Java对象的销毁都在新生代进行
-Xmn:设置新生代的空间大小,一般使用默认值
总结:
- 针对幸存者S0,S1区的总结:复制之后有交换,谁空谁是to。
- 关于垃圾回收:频繁在新生代收集,很少在老年代收集,几乎不在永久代/元空间收集
对象分配的特殊情况
在JVM进行GC时,并非每一次都会对(新生代、老年代、方法区)进行一起回收,一般回收的都是新生代
针对HotSpot VM的实现,里面的GC按照回收区域划分为两种类型:部分收集(Partial GC),一种是整堆收集(Full GC);
部分收集:不是对整个Java堆进行垃圾回收
- 新生代(Minor GC / Young GC)收集:只是新生代的垃圾收集
- 老年代(Major GC / Old GC)收集: 只是老年代的垃圾收集
- 目前,只有CMS GC 会有单独收集老年代的行为
- 注意:很多时候Major GC 和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆收集
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
- 目前,只有G1 GC会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
针对不同年龄段的对象分配原则:
- 优先分配到Eden
- 大对象直接分配老年代
- 尽量避免程序中出现大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断
- 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到MaxTenuringThreshold 中要求的年龄。
- 空间分配担保 :
- -XX:HandlePromotionFailure
为什么有TLAB(Thread Local Allocation Buffer)?
- 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为可避免多个线程 *** 作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB?
- 从内存模型的角度,对Eden区进行划分,JVM为每个线程分配一个私有缓存区域,它包含在Eden空间
- 多线程同时分配时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此可以将这种内存分配方式称之为快速分配策略
- 由OpenJDK衍生出来的JVM都提供TLAB。
详细说明TLAB
- 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
- 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间,默认开启。
- 默认情况下,TLAB空间内存非常小,仅占“Eden”区的1%;
- 如果TLAB的空间不够分配的,JVM会允许它溢出一小丢丢,如果还不够就会在堆中分配。
- TALB底层还是允许所有线程访问的,不过不允许其他线程在这里创建对象。
- -Xms:用于表示堆区的起始内存,等价于-XX:InitialHeapSize
- -Xmx:用于表示堆区的最大内存,等价于-XX:MaxHeapSize
- -Xmn:设置新生代的空间大小,一般使用默认值
- -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
- -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能存在修改,不再是初始值)
- 具体查看某进程中某个参数的指令:
- 步骤1、jps:查看当前运行的java程序进程
- 步骤2、jinfo -flag 参数名称 进程id
- -XX:NewRatio=2:配置老年代新生代在堆结构的占比 (Old:Young = 2:1)
- -XX:SurvivorRatio=8:配置新生代中Eden区和s1 /s0区的比例
- -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
- -XX:+PrintGCDetails:输出详细的GC处理日志
- -XX:+PrintGC或-verbose:gc:打印gc简要信息
- -XX:HandlePromotionFailure是否设置空间分配担保
1、堆是分配对象的唯一选择吗?
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识,但是,有一种特殊情况,就是经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,就有可能被优化成栈上分配。这样就无需堆上分配内存,也无需进行垃圾回收了。这是常见的堆外存储技术。
基于OpenJDK深度定制的TaobaoVM、其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GC内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
逃逸分析概述
逃逸分析:一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
- 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
- 逃逸分析的基本行为就是分析对象动态作用域
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例:作为调用参数传递到其他地方
- 判断是否发生逃逸:
- 就看new的对象实体是否有可能在方法外被调用。
- 在JDK 6u23版本之后,Hotspot中默认就已经开启了逃逸分析。
- 显示开启逃逸分析: -XX:+DoEscapeAnalysis
- 查看逃逸分析的筛结果:-XX:+PrintEscapeAnalysis
逃逸分析:代码优化:
使用逃逸分析,编译器可以对代码进行优化
- 一、栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要是指向对象的指针永不逃逸,对象可能是栈分配的候选,而不是堆分配
- 二、同步省略:如果一个对象被发现只能在从一个线程被访问到,那么对于这个对象的 *** 作可以不考虑同步(锁消除)
- 三、分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中
- 标量:指一个无法分解成更小的数据的数据。Java中的原始数据类型就是标量。
- 聚合量:还可以分解的数据
- 参数:-XX:+EliminateAllocations,Jdk7之后默认开启。
开启逃逸分析之后,使性能得到提升的主要还是标量替换,而不是栈上分配,其实Oracle的jdk并没有实现栈上分配,而是利用标量替换,存储在cpu寄存器中。
2.5、方法区从线程共享与否分析内存示意图
堆、栈、方法区的交互关系图解
Java虚拟机具有一个在所有Java虚拟机线程之间共享的_方法区域_。该方法区域类似于常规语言的编译代码的存储区域,或者类似于 *** 作系统过程中的“文本”段。它存储每个类的结构,例如运行时常量池,字段和方法数据,以及方法和构造函数的代码,包括用于类和实例初始化以及接口初始化的特殊方法。
方法区域是在虚拟机启动时创建的。尽管方法区域在逻辑上是堆的一部分,但是简单的实现可以选择不进行垃圾回收或压缩。该规范没有规定方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小的,或者可以根据计算的需要进行扩展,如果不需要更大的方法区域,则可以缩小。方法区域的内存不必是连续的。
Java虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,并且在方法区域大小可变的情况下,可以控制最大和最小方法区域大小。
以下异常条件与方法区域相关联:
- 如果无法提供方法区域中的内存来满足分配请求,则Java虚拟机将抛出一个OutOfMemoryError。
方法区在哪里?
在《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区有一个别名叫做“Non-Heap”(非堆),目的就是要和堆分开。
所以,方法区是一块独立于Java堆的内存空间。
永久代和元空间
- 在JDK8之前方法区的实现是永久代,JDK8之后,完全废弃永久代的概念而用元空间对方法区的进行实现
- 元空间与永久代的本质区别:元空间不在虚拟机设置的内存中,而是使用本地内存。
设置方法区的大小
在JDK7及以前:
- -XX:PermSize:来设置永久代初始分配空间,默认20.75M
- -XX:MaxPermSize: 来设定永久代最大可分配空间。32位默认是64M,63位默认是82M
- 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space.
在JDK8及以后:
- -XX:metaspaceSize: 来设置元空间初始分配空间,64位默认是21M
- -XX:MaxmetaspaceSize : 的值是 -1, 即没有上限限制
- 如果元空间发生了溢出,也会报OutOfMemoryError:metaspace
- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC 多次调用。为了避免频繁地GC,建议将
-XX:metaspaceSize设置为一个相对较高的值
方法区存储什么?
类型信息、常量、静态变量、即时编译器编译后的代码缓存,域信息,方法信息等。
- 静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
- 类变量被类的所有实例共享
全局常量static final
被声明为final的类变量在编译的时候就会被分配
探究字节码层面类变量(static) 与 全局常量(static final)的区别:
public static int count = 1; //类变量 public static final int number = 2; //全局常量
下图是,编译为字节码文件之后:
明显可以发现
类变量在编译期没有赋值,全局常量在编译期已被赋值
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域、和方法的符号引用。
为什么需要常量池?
常量池,可以看做是一张表,虚拟机指令根据这种常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池 从字节码文件理解方法区public class MethodAreaDemo { public static void main(String[] args) { int x = 500; int y = 100; int a = x / y; int b = 50; System.out.println( a + b); } }
编译之后的字节码文件
main方法的执行过程
永久代为什么要被元空间替换?
方法区的垃圾回收
2.6、总结 2.7、常见面试题Stirng Table为什么要调整?
创建对象的方式
创建对象的步骤
详细步骤
1、判断对象对应的类是否加载、链接、初始化
2、为对象分配内存
- 如果内存规整
- 指针碰撞
- 指针碰撞
- 如果内存不规整
- 虚拟机需要维护一个列表
- 空闲列表分配
- 说明
3、处理并发安全问题
- 采用CAS失败重试、区域加锁保证更新的原子性
- 每个线程预先分配一块TLAB - 通过 -XX:+/-UseTLAB参数来设定
4、初始化分配到的空间
- 所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
5、设置对象的对象头
6、执行init方法进行初始化
小结:图示所示
public class CustomerTest{ public static void main(String[] args){ Customer cust = newCustomer(); } }3.3、对象的访问定位
对象访问方式(两种):
1、句柄访问
好处:
坏处:需要在对空间额外申请内存,速度慢
2、直接指针(hotspot采用)
好处:不用再额外申请内存,速度快
坏处:不稳定,对象移动时,需要改变reference类型的值
- 直接内存不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 直接内存是在Java堆外的、直接向系统申请的内存空间
- 来源于NIO,通过存在堆中的DirectByteBuffer *** 作native内存
- 通常,访问直接内存的速度会优于Java堆。即读写性能高
- 因此出于性能考虑,读写频率频繁的场合可能会考虑使用直接内存
- Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。
- 也可能导致OutOfMemoryError异常
- 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于 *** 作系统能给出的最大内存。
- 缺点:
- 分配回收成本较高
- 不受JVM内存回收管理
- 直接内存大小可通过MaxDirectMemorySize设置
- 如果不指定,默认与堆的最大值-Xmx参数值一致。
简单理解:
Java process memory = java heap + native memory
直接缓冲区与非直接缓冲区的区别:
(即io/nio的区别)
图解 io
nio
通过案例 测试程序使用本地直接内存
public class BufferTest { private static final int BUFFER = 1024*1024*1024; public static void main(String[] args) { // 直接分配本地内存 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER); System.out.println("直接内存分配完毕,请指示"); Scanner scanner = new Scanner(System.in); scanner.next(); System.out.println("直接内存开始释放!"); byteBuffer = null; System.gc(); scanner.next(); } }
运行结果:
运行之前本地内存使用情况:
分配直接内存之后本地内存使用情况:
内存释放之后,本地内存使用情况:
执行引擎是Java虚拟机核心的组成部分之一
JVM的主要任务是负责装载字节码到其内部。
字节码并不能直接运行在 *** 作系统之上,因为字节码指令并不是等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他的辅助信息。
一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令。
Java代码编译/执行过程
大部分程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都要经过上图中的各个步骤。
java代码编译流程图
Java字节码执行流程图
对应关系
什么是解释器?什么是JIT编译器?
为什么Java是半编译半解释型语言?
图解解释器和JIT编译器
解释器工作机制
- 将字节码文件中内容“翻译”为对应平台的本地机器指令执行
- 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释 *** 作 。
解释器分类
字节码解释器,模板解释器
基于解释器执行已经沦落为低效的代名词
虚拟机将源代码直接编译为和本地机器相关的机器语言。
为了解决 解释器执行低效的问题,JVM平台支持一种即时编译的技术。
目的:是为了避免函数被解释执行,而是将整个函数体编译成为机器码。每次函数执行时,只执行编译后的机器码即可。
概念解释
编译器:
HotSpot JVM的执行方式
一个被多次调用的方法,或者是一个方法内部循环次数较多循环体都可称为“热点代码”。都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也称为栈上替换。简称OSR(On Stack Replacement )编译。
热点探测功能
用来探测那些代码为热点代码。
HotSpot VM采用 热点探测方式是 基于计数器的热点探测。
用于统计方法被调用的次数,它的默认阈值在Client模式下是1500下,在Server模式下是10000次,超过这个阈值,就会触发JIT编译。
这个阈值可以通过虚拟机参数“-XX:CompileThreshold”来设定。
具体描述
当一个方法被调用时,会先检查方法是否存在JIT编译版本,优先执行编译后的代码。如果不存在被JIT编译过的版本,则此方法的调用计数器+1.然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值,如果超过将向即时编译器提交一个该方法的代码编译请求。
图解
热度衰减
- String :字符串,使用“”引起来表示。
- String声明为final的,不可被继承。
- String
- 实现了Serializable接口:表示字符串是支持序列化的。
- 实现了Comparable接口:表示string可以比较大小。
- String在Jdk8以前内部定义了final char[] value 用于存储字符串数据。jdk9时改为了byte[] value
推荐阅读
1、常量与常量的拼接结果在常量池,原理:编译期优化
2、常量池不会存在相同内存的常量
3、只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
4、如果拼接的结果调用intern( )方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。
案例1、
常量与常量拼接
案例2、
只要其中有一个为变量,结果就在堆中
案例3、
变量与变量相加,底层原理
案例4、
当两个被标记final关键字的字符串相加时
String 拼接 + 和 append方法效率对比
结论:通过StringBuilder的append()方式添加字符串的效率要远高于使用String的+拼接方式(拼接变量 *** 作多的情况下);
详情
1、StringBuilder的append()方式:自始至终只创建一个StringBuilder对象
String的+字符串拼接方式:每次相加都会创建新的StringBuilder对象
2、String的+字符串拼接方式:内存中由于创建了较多的StringBuilder对象和String对象,内存占用更大;如果进行GC,需要花费更多的额外时间。
改进优化
在实际开发中,如果基本确定需要添加的字符串长度不高于某个限定值的情况下,建议使用
StringBuilder s= new StringBuilder(hightLevel) ;
创建指定大小的StringBuilder,相当于创建一个 new char[hightLevel]
关于intern()的面试题
面试题一:
String ab = new String("ab"); //会创建几个对象?看字节码,就知道是两个。 String ab = new String("a")+new String("b"); //思考:会创建几个?
答案 : 2 、6 (JDK 8)
解析:
面试题二: intern()
public static void main(String[] args) { String s1 = new String("1"); s1.intern(); String s2 = "1"; System.out.println(s1==s2); String s3 = new String("1")+new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3==s4); }
答案:
JDK6:fasle fasle
JDK7:fasle true
JDK8:fasle true
题解:
字节码指令
0 new #23 dup 4 ldc #3 <1> 6 invokespecial #4 > 9 astore_1 10 aload_1 11 invokevirtual #5 14 pop 15 ldc #3 <1> 17 astore_2 18 getstatic #6 21 aload_1 22 aload_2 23 if_acmpne 30 (+7) 26 iconst_1 27 goto 31 (+4) 30 iconst_0 31 invokevirtual #7 34 new #8 37 dup 38 invokespecial #9 > 41 new #2 44 dup 45 ldc #3 <1> 47 invokespecial #4 > 50 invokevirtual #10 53 new #2 56 dup 57 ldc #3 <1> 59 invokespecial #4 > 62 invokevirtual #10 65 invokevirtual #11 68 astore_3 69 aload_3 70 invokevirtual #5 73 pop 74 ldc #12 <11> 76 astore 4 78 getstatic #6 81 aload_3 82 aload 4 84 if_acmpne 91 (+7) 87 iconst_1 88 goto 92 (+4) 91 iconst_0 92 invokevirtual #7 95 return
public static void main(String[] args) { String s1 = new String("1"); //调用此方法之前,字符串常量池中已经存在了 “1” 原因可看String的构造器,和字节码 s1.intern(); String s2 = "1"; System.out.println(s1==s2); //jdk6:false jdk7/8:false // s3变量记录地址为:new String(value, 0, count); // 此时并不会在字符串常量池中创建 "11" // 原因: // new String("abc");会创建两个对象,其中一个是在常量池中创建一个“11”, // 是因为String的构造方法的参数是一个字符串(“11”); // new String(value, 0, count) 中的value是一个 char[] ,并不会在 // 字符串常量池中创建一个“11”, String s3 = new String("1")+new String("1"); s3.intern(); // 在字符串常量池中生成“11”。 // 如何理解: // JDK6:创建一个新的对象“11”,在字符串常量池中也创建一个 “11” // JDK7: 此时常量池中并没有创建常量“11”,而是创建一个指向堆空间中new String对象时创建的“11”的地址 // JDK8: 同JDK7 String s4 = "11"; //s4变量记录的地址:使用的是上一行代码执行时,在常量池中生成的“11”的地址 System.out.println(s3==s4); //jdk6 false jdk7/8 true }
intern()方法(红色部分可以解释 JDK8 在常量池中创建指向new String(“11”)对象的引用)
题解总结:
public static void main(String[] args){ String s=new String("1") s.intern; String s2="1"; System.out.println(s==s2);//false }
第一句: Strings=newString(“1”);
第二句: s.intern();发现字符串常量池中已经存在"1"字符串对象,直接返回字符串常量池中对堆的引用(但没有接收)–>此时s引用还是指向着堆中的对象
第三句: Strings2=“1”;发现字符串常量池已经保存了该对象的引用了,直接返回字符串常量池对堆中字符串的引用
很容易看到,两条引用是不一样的!所以返回false。
public static void main(String[] args) { String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4); // true }
第一句: Strings3=newString(“1”)+newString(“1”); 注意:此时**"11"对象并没有在字符串常量池中保存引用**。
第二句: s3.intern();发现"11"对象并没有在字符串常量池中,于是将"11"对象在字符串常量池中保存当前字符串的引用,并返回当前字符串的引用(但没有接收)
第三句: Strings4=“11”;发现字符串常量池已经存在引用了,直接返回(拿到的也是与s3相同指向的引用)
根据上述所说的:最后会返回true~~~
实现
命令行选项
垃圾收集:并不是Java语言的伴生产物, 1960年,第一门使用内存动态分配和垃圾收集技术的语言Lisp诞生
垃圾收集的三个问题:
- 那些内存需要回收?
- 什么时候回收?
- 如何回收?
什么是垃圾?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是被回收的垃圾
如果不能对内存中的垃圾进行及时清理,这些垃圾对象就会一直存在至应用程序结束,被保留的空间无法被其他对象使用,就可能导致内存溢出。
- 自动内存管理 ,降低内存泄露和内存溢出的危险
- 自动内存管理,可以让程序员不用专注于内存的申请与释放,更专注于业务开发
垃圾回收器可以针对年轻代回收,也可以针对老年代回收,甚至是全堆或方法区的回收。(Java堆是垃圾收集器的工作重点)
**内存泄露 **:是指程序在申请内存后,无法释放已申请的内存空间就造成了内存泄漏,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
我们知道了内存泄漏的原因而内存溢出则有可能是因为我们我们多次内存泄漏堆积后的后果则变成了内存溢出
内存溢出: 指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储int类型数据的存储空间,但是你却存储long类型的数据,那么结果就是内存不够用,此时就会报错OOM,即所谓的内存溢出,简单来说就是自己所需要使用的空间比我们拥有的内存大内存不够使用所造成的内存溢出。
收集次数
- 频繁收集Young区
- 较少收集old区
- 基本不动Perm区(元空间)
垃圾标记阶段:对象存活判断
- 在GC执行垃圾回收之前,需要先区分内存中哪些是存活对象,哪些是已经死亡的对象。只有标记已经死亡的对象,GC才会在执行垃圾回收时,释放掉其占用的内存。因此该阶段称为垃圾标记阶段
- 在JVM中,当一个对象不再被任何存活对象继续引用时,就是代表已经死亡。
- 判断对象存活的两种方式:引用计数算法、可达性分析算法
垃圾清除阶段:清除死亡对象
- 当成功区分出内存中存活和死亡对象后,GC接下来的任务就是垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存为新对象分配内存。
- 常用的三种垃圾收集算法
- 标记-清除算法(Mark-Sweep)
- 标记-复制算法(Copying)
- 标记-压缩(整理)算法(Mark-Compact)
垃圾标记阶段的算法,对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。
解释:
有一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1.只要对象A的引用计数器的值为0,即表示对象A不可能被再使用,可进行回收
优点
实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点
- 需要单独的字段存储计数器,增加了存储空间的开销
- 每次赋值都需要更新计数器,伴随着加法和减法 *** 作,增加了时间开销
- 无法处理循环引用。
循环引用
Java没有使用引用计数,正是因为引用计数算法无法处理循环引用
Python用了引用计数算法
根搜索算法、追踪性垃圾收集
相对于引用计数算法,可达性分析算法不仅同样具备实现简单和执行高效等特点,还解决了引用计数算法中循环引用的问题,防止内存泄漏的发生
java 和 c# 使用 可达性分析算法
“GC Roots”根集合就是一组必须活跃的引用
理解:
图解:
Java语言中,GC Roots 包括以下几类元素(重点)
技巧:由于Root 采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但自己又不存放在堆内存中,那它就是一个Root
注意:
对象终止(finalization)机制 允许开发人员提供对象被销毁之前的自定义处理逻辑
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,先调用这个对象的finalize()方法。
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接
工作过程
判断对象是否可以回收,至少经历两次标记过程
标记清除算法
背景
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。
执行过程
当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为Stop The World),然后进行两项工作,第一项是标记,第二项则是清除。
- 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。
- 清除:Collector对堆内内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
图解
缺点
- 效率不算高
- 在进行GC的时候,需要停止整个应用程序,导致用户体验差
- 这种方式清理出来的内存是不连续的,产生内存碎片,需要维护一个空闲列表;
注意:** 什么是清除?**
这里的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。下次用的时候直接覆盖
复制算法
背景
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,“使用双存储区的Lisp语言垃圾收集器CA Lisp Garbage Collector Algorithm Using Serial Secondary Storage”。M.L.Minsky在论文中描述的算法被人们称为复制(Copying)算法,它也被M.L.Minsky本人成功的引入到了Lisp语言的一个实现版本中。
核心思想
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
图解
优点
- 没有标记,清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”空间
缺点
- 需要两倍的内存空间
- 对于G1这种分拆成大量region的GC,复制而不是移动,意味着维护region之间对象引用关系,不管是内存占用或者时间开销都不会少
- 如果系统中垃圾对象比较多,复制算法需要复制的存活对象数量并不会太大或者非常低,但是如果垃圾比较少,那复制存活对象代价就太大了
应用场景
标记整理压缩算法
背景
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象多,复制成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其它的算法。
标记-清除算法确实可以应用在老年代中,但是,该算法不仅执行效率低下,而且在执行完内存收集后,还会产生内存碎片,所以JVM的设计者需要在此基础上进行改进。标记-压缩(Mark Compact)算法由此诞生。
1970年前后,G.L.Steel、C.J.Chene和D.S.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
执行过程
- 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象
- 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放
- 然后,清理边界外所有的空间
图解
标记-压缩算法 与标记-清除算法 差异
- 标记-压缩算法 最终效果 相当于标记-清除算法执行之后再进行一次内存碎片整理,因此可以成为 标记-清除-压缩算法
- 本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的,是否移动回收后的存活对象是优缺点并存的风险决策
- 标记整理算法:标记存活的对象将被整理,按照内存地址依次排列,未被标记的内存会被清理。 所以,当我们需要给新对象分配内存时,JVM只需要持有一个内存起始地址即可,这比维护空闲列表少很多开销
优点
- 清除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点
- 从效率上来说,标记-整理算法要低于复制算法。
- 移动对象的同时,如果对象被其它对象引用,则还需要调整引用 的地址。
- 移动过程中,需要全程暂停用户应用程序,即:STW。
清除算法和压缩算法完整的过程是:识别->标记->遍历清除未标记对象;而复制算法是:识别->复制被GCroot引用的对象->直接置空原区域
分代收集算法
没有一种算法可以代替其它算法,他们都有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采用不同的收集方式,以便提高回收效率。 一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务有关,比如:http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是,还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变的特性,系统会产生大量的这些对象,有些对象甚至只用一次就可回收。
注意: 目前几乎所有的GC都是采用分代收集(Generational Collecting)算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代的特点。
- 年轻代特点:
- 区域相对老年代较小,对象生命周期短、存活率低、回收频繁;这种情况复制算法的回收整理,速度是最快的。
- 复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到解决。
- 老年代特点:
- 区域较大,生命周期长,存活率高,回收频率不及年轻代。
- 这种情况存在大量存活率高的对象,复制算法明显不合适,一般用标记-清除算法或者标记-清除和标记-压缩算法的混合实现。
- mark阶段的开销与存活对象数量成正比;
- Sweep阶段的开销与管理区域大小成正比;
- Compact阶段的开销与存活对象成正比。
以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old 回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old 执行Full GC以达到对老年代整理的目的。
增量收集算法
现有算法,在垃圾回收过程中,应用软件将处于一种Stop the World 的状态。**在Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。**如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。
基本思想
如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法
一般来说,在相同条件下,堆空间越大,一次GC所需要的时间越长,有关GC产生的停顿越长。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一次GC停顿的时间。
分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。
每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
图解
注意: 目前发展中的前沿GC都是复合算法,且并行与并发兼备
6.5、Safe-Point SafePoint和Stop the world?关系?
- 当需要 GC 时,需要知道哪些对象还被使用,或者已经不被使用可以回收了,这样就需要每个线程的对象使用情况。
- 对于偏向锁(Biased Lock),在高并发时想要解除偏置,需要线程状态还有获取锁的线程的精确信息。
- 对方法进行即时编译优化(OSR栈上替换),或者反优化(bailout栈上反优化),这需要线程究竟运行到方法的哪里的信息。
对于这些 *** 作,都需要线程的各种信息,例如寄存器中到底有啥,堆使用信息以及栈方法代码信息等,并且做这些 *** 作的时候,线程需要暂停,等到这些 *** 作完成,否则会有并发问题。这就需要 SafePoint。
Safepoint 可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,线程可以暂停。在 SafePoint 保存了其他位置没有的一些当前线程的运行信息,供其他线程读取。这些信息包括:线程上下文的任何信息,例如对象或者非对象的内部指针等等。我们一般这么理解 SafePoint,就是线程只有运行到了 SafePoint 的位置,他的一切状态信息,才是确定的,也只有这个时候,才知道这个线程用了哪些内存,没有用哪些;并且,只有线程处于 SafePoint 位置,这时候对 JVM 的堆栈信息进行修改,例如回收某一部分不用的内存,线程才会感知到,之后继续运行,每个线程都有一份自己的内存使用快照,这时候其他线程对于内存使用的修改,线程就不知道了,只有在进行到 SafePoint 的时候,才会感知。
所以,GC 一定需要所有线程同时进入 SafePoint,并停留在那里,等待 GC 处理完内存,再让所有线程继续执。像这种所有线程进入 SafePoint 等待的情况,就是 Stop the world。
在 SafePoint 位置保存了线程上下文中的任何东西,包括对象,指向对象或非对象的内部指针,在线程处于 SafePoint 的时候,对这些信息进行修改,线程才能感知到。有一个重要的 Java 线程特性也是基于 SafePoint 实现的,Thread.interrupt(),线程只有运行到 SafePoint 才知道是否 interrupted。
为啥需要 Stop The World,有时候我们需要全局所有线程进入 SafePoint 这样才能统计出哪些内存可以回收用于 GC,以及回收不再使用的代码清理 CodeCache,以及执行某些 Java instrument 命令或者 JDK 工具,例如 jstack 打印堆栈就需要 Stop the world 获取当前所有线程快照。
JVM会设置一个状态标记表示要进入sp,每个线程都在合适的时机检查这个标记,如果需要就暂停自己。经过JIT编译的代码,SafePoint 可以插入到代码的某些位置,每个线程运行到 SafePoint 代码时,主动去检查是否需要进入 SafePoint,这个主动检查的过程,被称为 Polling
理论上,可以在每条 Java 编译后的字节码的边界,都放一个检查 Safepoint 的机器命令。线程执行到这里的时候,会执行 Polling 询问 JVM 是否需要进入 SafePoint,这个询问是会有性能损耗的,所以 JIT 会优化尽量减少 SafePoint。经过JIT优化后,会在方法返回前和无界循环回跳前放一个safepoint,防止GC需要stw时线程不能暂停。
- 定时进入 SafePoint:每经过-XX:GuaranteedSafepointInterval 配置的时间,都会让所有线程进入 Safepoint,一旦所有线程都进入,立刻从 Safepoint 恢复。这个定时主要是为了一些没必要立刻 Stop the world 的任务执行,可以设置-XX:GuaranteedSafepointInterval=0关闭这个定时,我推荐是关闭。
- 由于 jstack,jmap 和 jstat 等命令,也就是 Signal Dispatcher 线程要处理的大部分命令,都会导致 Stop the world:这种命令都需要采集堆栈信息,所以需要所有线程进入 Safepoint 并暂停。
- 偏向锁取消(这个不一定会引发整体的 Stop the world,参考JEP 312: Thread-Local Handshakes):Java 认为,锁大部分情况是没有竞争的(某个同步块大多数情况都不会出现多线程同时竞争锁),所以可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时,会判断是否偏向锁指向我,那么该线程将不用再次获得锁,直接就可以进入同步块。但是高并发的情况下,偏向锁会经常失效,导致需要取消偏向锁,取消偏向锁的时候,需要 Stop the world,因为要获取每个线程使用锁的状态以及运行状态。
- Java Instrument 导致的 Agent 加载以及类的重定义:由于涉及到类重定义,需要修改栈上和这个类相关的信息,所以需要 Stop the world
- Java Code Cache相关:当发生 JIT 编译优化或者去优化,需要 OSR 或者 Bailout 或者清理代码缓存的时候,由于需要读取线程执行的方法以及改变线程执行的方法,所以需要 Stop the world
- GC:这个由于需要每个线程的对象使用信息,以及回收一些对象,释放某些堆内存或者直接内存,所以需要 Stop the world
- JFR 的一些事件:如果开启了 JFR 的 OldObject 采集,这个是定时采集一些存活时间比较久的对象,所以需要 Stop the world。同时,JFR 在 dump 的时候,由于每个线程都有一个 JFR 事件的 buffer,需要将 buffer 中的事件采集出来,所以需要 Stop the world。
- 建议关闭定时让所有线程进入 Safepoint
对于微服务高并发应用,没必要定时进入 Safepoint,所以关闭 -XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0
- 建议取消偏向锁
在高并发应用中,偏向锁并不能带来性能提升,反而因为偏向锁取消带来了很多没必要的某些线程进入Safepoint 或者 Stop the world。所以建议关闭:-XX:-UseBiasedLocking
- 建议打开循环内添加 Safepoint 参数
防止大循环 JIT 编译导致内部 Safepoint 被优化省略,导致进入 SafePoint 时间变长:-XX:+UseCountedLoopSafepoints
- 建议打开 debug 级别的 safepoint 日志(和第五个选一个)
debug 级别虽然看不到每次是哪些线程需要等待进入 Safepoint,但是整体每阶段耗时已经很清楚了。如果是 trace 级别,每次都能看到是那些线程,但是这样每次进入 safepoint 时间就会增加几毫秒。
-Xlog:safepoint=debug:file=safepoint.log:utctime,level,tags:filecount=50,filesize=100M
- 建议打开 JFR 关于 safepoint 的采集(和第四个选一个)
默认情况下,通过System.gc()或者Runtime.getRuntime().gc()的调用,会显示的出发Full GC,同时对老年代与新生代进行回收,尝试释放被丢弃对象占用的内存
System.gc( ) 的底层就是Runtime.getRuntime().gc()
System.gc( ) 调用附带一个免责声明,无法保证对垃圾收集器的调用。即仅仅是提醒,不能保证垃圾收集的具体执行时间
一般垃圾回收都是自动进行的,在一些特殊情况下才会手动通过System.gc() 调用垃圾回收。
概述
- 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
- 由于GC一直在发展,所以一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况;
- 大多数情况下,GC会进行各年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC *** 作,这时候会回收大量的内存,供应用程序继续使用。
- javadoc中对OutOfMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
没有内存原因?
首先说没有空闲内存的情况,Java虚拟机堆内存不够,原因有二:
- Java虚拟机的堆内存设置不够
- 比如:可能存在内存泄漏问题,也很有可能就是堆的大小不合理,比如我们要处理可观的数据量,但是,没有显示指定JVM堆大小或者指定数值偏小,我们可以通过-Xms、-Xmx来调整。
- 代码中创建了大量对象,并且长时间不能被垃圾收集器收集(存在引用);
- 对于老版本的Oracle JDK,因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如:常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息会标记出来和永久代有关:“java.lang.OutOfMemeoryError:PermGen space”
- 随着元数据区的引入,方法区内存不再那么窘迫,所以相应的OOM有所改观,出现的异常信息变成了:“java.lang.OutOfMemoryError:metaspace”。直接内存不足也会导致OOM。
这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理空间。
- 例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
- 在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发。
- 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接抛出OutOfMemoryError.
内存泄漏
概述
也称作“存储泄漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收它们的情况,才叫内存泄漏。
实际情况,很多时候一些不太友好的实践(或疏忽)会导致对象的生命周期变得很长,甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。
尽管内存泄漏并不会立刻引起程序崩溃,但是,一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。
注意:这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘区交换设定的大小。
图解
例:
例一:单例模式
单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。
例二:一些提供close的资源未关闭导致内存泄漏
数据库连接(dataSource.getConnection()),网络连接(socket)和IO连接必须手动Close(),否则是不能被回收的。
6.5.3、Stop The World
Stop-The-World ,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何相应,有点像卡死的感觉,这个停顿称为STW。
可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。
- 分析工作必须在一个能确保一致性的快照中进行。
- 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。
- 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成卡带一样,所以我们需要减少STW的发生。
注意:
- STW和采用哪款GC无关,所有的GC都有这个事件。
- 哪怕是G1也不能完全避免Stop-The-World情况的发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
- STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常工作的线程全部停掉。
- 开发中不要用System.gc(),会导致Stop-The-World的发生。
图解
当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行
注意:
- 其实决定并行的因素并不是CPU的数量,而是CPU的核心数量,比如一个CPU多核也可以并行
- 适合科学计算,后台处理等弱交互场景
图解
并发/并行区别
并发与并行,在谈论垃圾收集器的上下文语境中,可以解释如下:
- 并行(Parallel):指多条垃圾收集线程并行工作,但此用户线程仍处于等待状态。
- 如 ParNew、Parallel Scavenge、Parallel Old
- 串行(Serial)
- 相较于并行的概念,单线程执行
- 如果内存不足,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能是交替进行),垃圾回收线程在执行时不会停顿用户程序的运行
- 用户程序再继续运行,而垃圾收集程序线程 运行在另一个CPU上
- 如:CMS 、 G1
程序只能在特定的位置停顿下来开始GC,这些位置称为“安全点”
Safe Point 选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂、通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?如线程处于Sleep状态或Blocked状态,这时 候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。
实际执行时:
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
除强引用外,其他三种引用均可在java.lang.ref包中找到他们的身影。如图
- 强引用(StrongReference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“object obj=new Objectd)”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。 (内存不足,才回收)
- 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。(发现即回收)
- 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
强引用
(StrongReference)不死不回收
最常见的引用方式,(普通系统99%都是强引用),默认的引用类型。
当Java语言中使用new *** 作符创建一个对象,并将其赋值给一个变量时,这个变量就成为指向该对象的一个强引用。
强引用的对象时可触及的,垃圾收集器就永远不会回收掉被引用的对象。
对于一个普通的对象,如果没有其他的引用关系,只要超过引用的作用域或者显示地将相应(强)引用赋值为null,就可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
软引用
(SoftReference)内存不足即回收
用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果此次回收仍然内存不足,才会抛出内存溢出异常
软引用通常用来实现内存敏感的缓存。比如:高速缓存。如果还用空闲内存,就可以暂时保留缓存,但内存不足时清理掉,保证了使用缓存的同时又不会耗尽内存
垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列。
弱引用
(Weak Reference )发现即回收
用来描述那些非必须对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象
但是,由于垃圾回收线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象,在这种情况下,弱引用对象可能存在较长的时间。
弱引用和软引用一样,在构造弱引用是,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通常这个队列可以跟踪对象的回收情况。
软引用、弱引用都非常适合来保存哪些可有可无的缓存数据。
虚引用
(Phantom Reference)对象回收跟踪
也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。
一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。
不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null。
终接器引用
它用以实现对象的finalize()方法,也可以称为终结器引用。
无需手动编码,其内部配合引用队列使用。
在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次Gc时才能回收被引用对象。
分代收集理论中新生代引入了记忆集,链接老年代防止被回收。事实上所有涉及部分区域回收的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器都面临这个问题。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果不考虑效率和成本,最简单可用非收集区域中所有含跨代引用的对象数组来实现这个结构。但是这种记录全部含跨代引用对象的方案比较麻烦。
实现记忆集时,可选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择的记录精度:
1.字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问 物理内存地址的指针长度),该字包含跨代指针。
2.对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
3.卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
其中,卡精度所指的是一种称为“卡表”的方式,这也是目前最常用的一种记忆集实现形式。记忆集是一种“抽象”的数据结构,即只定义了行为意图,并没有定义具体实现。但是卡表是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表最简单的形式可以只是一个字节数组,而Hotspot中也是这么使用的。
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一般来说,卡页大小都是以2的N次幂的字节数,Hotspot中使用的卡页是2的9次幂,即512字节。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识0。在垃圾收集时,只要筛选出卡表中变脏的元素,就能知道哪些卡页内存块中包含跨代指针,把它们加入GC Roots一并扫描。
卡表元素何时变脏很明确,但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表。假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中,经过即时刻编译后的代码已经是纯粹的机器指令流了,这就必须找到一个机器码层面的手段。
在Hotspot虚拟机里是通过写屏障技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范围内。在赋值前的叫做写前屏障,在赋值后的叫写后屏障。Hotspot虚拟机的许多收集器都使用到写屏障,但直到G1收集器出现前,其它都只用到了写后屏障。
应用写屏障后,虚拟机就会为所有赋值 *** 作生成相应的指令,一旦收集器在写屏障中增加了更新卡表 *** 作,就会产生额外开销。
除了写屏障的开销外,卡表在高并发场景下还面临“伪共享”问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
可达性分析算法理论上要求基于一个能保障一致性的快照中才能分析,即全程冻结用户线程的执行。扫描GC Roots尚为简单,但是向下遍历的耗时就与Java堆容量成正比了:堆越大,存储对象越多,扫描越费时。“标记”阶段是所有追踪式垃圾收集算法的共同特征,耗时也与Java堆相关。这俩都会导致用户线程停顿过长。
要解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历。为了解释这个问题,引入了三色标记作为工具辅助推倒。把遍历过程遇到的对象,按照“是否访问过”标记成以下三种颜色:
·白色:表示对象尚未被垃圾收集器访问过。显然分析刚开始时所有对象都是白色,分析结束后,仍然是白色的则代表不可达。
·黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色代表已经扫描过,安全活下来的。如果有其他对象引用指向了黑色对象,无需重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
·灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下图演示了这样的致命错误具体是如何产生的。
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
·赋值器插入了一条或多条从黑色对象到白色对象的新引用;
·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
因此,要解决并发扫描时的对象消失问题,只需破坏其中一个条件即可。由此产生了两种解决方法:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB).
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再以这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再以这些记录过的引用关系中的灰色对象为根,查询扫描一次。这也可以简化理解为,无论引用关系删除是否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录 *** 作都是通过写屏障实现的。在Hotspot虚拟机中,例如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
- 垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
- 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。
- 从不同角度分析垃圾收集器,可以将GC分为不同的类型。
1、按线程数分:串行垃圾回收器和并行垃圾回收器
- 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收 *** 作,此时工作线程被暂停,直至垃圾收集工作结束。
- 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中
- 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
- 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“stop-the-world”机制。
2、按工作模式分:并发式垃圾回收器和独占式垃圾回收器
- 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
- 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
3、按碎片处理方式分:压缩式垃圾回收器和非压缩式垃圾回收器
- 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
- 再分配对象空间使用:指针碰撞
- 非压缩式的垃圾回收器不进行这步 *** 作。
- 再分配对象空间使用:空闲列表
4、按工作内存区间分:年轻代垃圾回收器和老年代垃圾回收器
评估GC的性能指标- 吞吐量:运行用户代码的时间占总运行时间的比例
- 总运行时间:用户代码运行时间 + 内存回收时间
- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行的比例
- 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
- 收集频率:相对于应用程序的执行,手机 *** 作发生的频率
- 内存占用:Java堆区所占的内存大小
- 快速:一个对象从诞生到被回收所经历的时间
吞吐量(throu)
指的是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即
吞吐量 = 运行用户代码时间/(运行用户代码时间+垃圾收集时间)
比如:虚拟机总共运行了180分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。
吞吐量优先,意味着在单位时间内,STW的时间最短:0.2+0.2=0.4
指一个时间段内应用程序线程暂停,让Gc线程执行的状态
比如,GC期间100毫秒的暂停时间意味着在这1ee毫秒期间内没有应用程序线程是活动的。
暂停时间优先,意味着尽可能让单次STW的时间最短:0.1+0.1+0.1+0.1+0.1=0.5
在设计(或使用)GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
现在标准:在最大吞吐量优先的情况下,降低停顿时间。
7款经典的垃圾收集器
7款经典垃圾收集器与垃圾分代之间的关系
如何查看默认的垃圾收集器?垃圾收集器的组合关系
1、-XX:+PrintCommandLineFlags: 查看命令行参数(包含使用的垃圾收集器)
2、使用命令行指令: jinfo -flag 相关垃圾收集器参数 进程ID
Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial old收集器。
Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
Serial old是运行在client模式下默认的老年代的垃圾回收器
机制:
Serial收集器采用复制算法、串行回收和"stop-the-World"机制的方式执行内存回收。
Serial old收集器同样也采用了串行回收和"stop the World"机制,只不过内存回收算法使用的是标记-压缩算法。
Serial old在Server模式下主要有两个用途:
①与新生代的Parallel Scavenge配合使用
②作为老年代CMS收集器的后备垃圾收集方案
图示
这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束
优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
- 运行在client模式下的虚拟机是个不错的选择。
在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生,使用串行回收器是可以接受的。
设置
在Hotspot虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。
- 等价于新生代用SerialGC,且老年代用Serial old GC
总结
这种垃圾收集器大家了解就行,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了。
对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。
ParNew回收器
并行回收
ParNew收集器相当于Serial收集器的多线程版本
- Par 是 Parallel 的缩写,New:只能处理的是新生代
ParNew收集器除了采用并行回收的方式执行内存回收外,其他方面和Serial收集器几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、“Stop-the-World”机制。
ParNew 是很多JVM运行在Server模式下新生代的默认垃圾收集器。
- 对于新生代,回收次数频繁,使用并行方式高效
- 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换是线程,串行可以省去切换线程的资源)
面试题
由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?
- ParNew收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
- 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作
设置
-XX:+UseParNewGC指定使用ParNew收集器执行内存回收任务。表示年轻代使用ParNew并行收集器,不影响老年代
-XX:ParallelGCThreads限制线程数量,默认开启和CPU相同的线程数
Java 8中,默认的垃圾收集器
Parallel Scavenge回收器
吞吐量优先
Parallel Scavenge收集器和ParNew收集器一样是采用了复制算法、并行回收和“Stop the World”机制。
Parallel Old回收器
Parallel 收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器,采用了标记-压缩算法,基于并行回收和"stop-the-World"机制。
既然有了ParNew,那么Parallel收集器的出现是否是多此一举?
- 和ParNew收集器不同, parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),他也被称为吞吐量优先的垃圾收集器。
- 自适应调节策略也是Parrallel Scavenge与ParNew的一个重要区别
使用场景
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
设置
-XX:+UseParallelGC 手动指定年轻代使用Parallel并行收集器执行内存回收任务
-XX:+UseParallelOldGC手动指定老年代使用并行回收收集器。
- 上面两个参数,默认开启一个,另一个也会被开启。(相互激活)。默认JDK8是开启的
-XX:+ParallelGCThreads设置年轻代并行收集器的线程数。一般的最好和CPU数相等,以避免过多的线程数影响垃圾收集的性能
- 默认情况下,当CPU数量小于8个,parallelGCThreads的值等于CPU数量
- 当CPU数量大于8个,ParallelGCThreads的值等于3+[5*CPU_Count]/8
-XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STw的时间)。单位是毫秒。
- 为了尽可能地把停顿时间控制在MaxGCPauseMi11s以内,收集器在工作时会调整Java堆大小或者其他一些参数。
- 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Paralle1,进行控制。
- 该参数使用需谨慎。
-XX:GCTimeRatio 垃圾收集时间占总时间的比例 1/(N+1)。
- 用于衡量吞吐量的大小。
- 取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1s。
- 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
-XX:+UseAdaptiveSizePolicy 设置Parallel Scavenge收集器具有自适应调节策略
- 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
- 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMi11s),让虚拟机自己完成
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。由于响应快,目前广泛适用于网站或基于浏览器的B/S系统的服务端。
基于标记-清除算法。整个过程分为四步:
- 初始标记:暂停所有的其他线程,并记录下直接与root相连的对象,速度很快;
- 并发标记:同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记:重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清除:开启用户线程,同时GC线程开始对未标记的区域做清扫。
其中初始标记、重新标记这两步仍然需要暂停线程。初始标记只是标记一下GC Roots能直接关联到的对象;并发标记是从GC Roots的直接关联对象出发,遍历整个对象图,可以与垃圾收集线程一起并发运行;重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常比初始标记长,但比并发标记的短;并发清除是清理掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,这个阶段可以和用户线程并发。
CMS收集器有并发收集、低停顿的特点,但也有一些缺点:
- CMS收集器对处理器资源很敏感。
- 然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current ModeFailure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。
- 基于标记-清除,会产生大量空间碎片。
Garbage First收集器
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region。包括:Eden、Survivor、Old 和 Humongous。
其中,Humongous 是特殊的 Old 类型,回收空闲巨型分区,专门放置大型对象。这样的划分方式意味着不需要一个连续的内存空间管理对象。G1 将空间分为多个区域,优先回收垃圾最多的区域。
收集步骤:
- 初始标记:初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 的值,让下一个阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这一阶段需要停顿线程,但是耗时很短。
- 并发标记:并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记:而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remenbered Set Logs 里面,最终标记阶段需要把Remembered Set Logs 的数据合并到 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这一阶段需要停顿线程,但是可并行执行。
- 筛选回收:最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。
与 CMS 的 标记-清除 算法不同,G1 从整体看来是基于 标记-整理 算法实现的收集器,从局部(两个 Region 之间)上看是基于 复制 算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
面试篇 1、详细jvm内存结构JVM 内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
内存泄漏的原因很简单:
- 对象是可达的(一直被引用)
- 但是对象不会被使用
常见的内存泄漏例子:
public static void main(String[] args) { Set set = new HashSet(); for (int i = 0; i < 10; i++) { objectObject = new Object(); set.add(object); // 设置为空,这对象我不再用了 object = null; } // 但是set集合中还维护这obj的引用,gc不会回收object对象 System.out.println(set); }
解决这个内存泄漏问题也很简单,将set设置为null,那就可以避免上诉内存泄漏问题了。其他内存泄漏得一步一步分析了。
内存泄漏参考资料:
- https://www.ibm.com/developerworks/cn/java/l-JavaMemoryLeak/
内存溢出的原因:
- 内存泄露导致堆栈内存不断增大,从而引发内存溢出。
- 大量的jar,class文件加载,装载类的空间不够,溢出
- *** 作大量的对象导致堆内存空间已经用满了,溢出
- nio直接 *** 作内存,内存过大导致溢出
解决:
- 查看程序是否存在内存泄漏的问题
- 设置参数加大空间
- 代码中是否存在死循环或循环产生过多重复的对象实体、
- 查看是否使用了nio直接 *** 作内存。
JVM规范让每个Java线程拥有自己的独立的JVM栈,也就是Java方法的调用栈。
当方法调用的时候,会生成一个栈帧。栈帧是保存在虚拟机栈中的,栈帧存储了方法的局部变量表、 *** 作数栈、动态连接和方法返回地址等信息
线程运行过程中,只有一个栈帧是处于活跃状态,称为“当前活跃栈帧”,当前活动栈帧始终是虚拟机栈的栈顶元素。
通过jstack工具查看线程状态
参考资料:
- http://wangwengcn.iteye.com/blog/1622195
- https://www.cnblogs.com/Codenewbie/p/6184898.html
- https://blog.csdn.net/u011734144/article/details/60965155
- 部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代。
- 如果对象的大小大于Eden的二分之一会直接分配在old,如果old也分配不下,会做一次major GC,如果小于eden的一半但是没有足够的空间,就进行minor gc也就是新生代GC。
- minor gc后,survivor仍然放不下,则放到老年代
- 动态年龄判断 ,大于等于某个年龄的对象超过了survivor空间一半 ,大于等于某个年龄的对象直接进入老年代
这题就依据full GC的触发条件来做:
- 如果有perm gen(永久代)的话(jdk1.8就没了),要给perm gen分配空间,但没有足够的空间时,会触发full gc。 - 所以看看是不是perm gen区的值设置得太小了。
- System.gc()方法的调用 - 这个一般没人去调用吧~~~
- 当统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间,则会触发full gc(这就可以从多个角度上看了) - 是不是频繁创建了大对象(也有可能eden区设置过小)(大对象直接分配在老年代中,导致老年代空间不足—>从而频繁gc) - 是不是老年代的空间设置过小了(Minor GC几个对象就大于老年代的剩余空间了)
双亲委托模型的重要用途是为了解决类载入过程中的安全性问题。
- 假设有一个开发者自己编写了一个名为 java.lang.Object的类,想借此欺骗JVM。现在他要使用自定义 ClassLoader来加载自己编写的 java.lang.Object类。
- 然而幸运的是,双亲委托模型不会让他成功。因为JVM会优先在 BootstrapClassLoader的路径下找到 java.lang.Object类,并载入它
Java的类加载是否一定遵循双亲委托模型?
- 在实际开发中,我们可以通过自定义ClassLoader,并重写父类的loadClass方法,来打破这一机制。
- SPI就是打破了双亲委托机制的(SPI:服务提供发现)。SPI资料:
- https://zhuanlan.zhihu.com/p/28909673 -https://www.cnblogs.com/huzi007/p/6679215.html - https://blog.csdn.net/sigangjun/article/details/79071850
- tomcat也打破了
- 1. 父类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
- 2. 子类静态成员和静态初始化块 ,按在代码中出现的顺序依次执行
- 3. 父类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
- 4. 父类构造方法
- 5. 子类实例成员和实例初始化块 ,按在代码中出现的顺序依次执行
- 6. 子类构造方法
当young gen中的eden区分配满的时候触发MinorGC(新生代的空间不够放的时候).
9、JVM 中一次完整的 GC 流程(从 ygc 到 fgc)是怎样的YGC和FGC是什么
- YGC :对新生代堆进行gc。频率比较高,因为大部分对象的存活寿命较短,在新生代里被回收。性能耗费较小。
- FGC :全堆范围的gc。默认堆空间使用到达80%(可调整)的时候会触发fgc。以我们生产环境为例,一般比较少会触发fgc,有时10天或一周左右会有一次。
什么时候执行YGC和FGC
- a.eden空间不足,执行 young gc
- b.old空间不足,perm空间不足,调用方法 System.gc() ,ygc时的悲观策略, dump live的内存信息时(jmap –dump:live),都会执行full gc
- Serial收集器,串行收集器是最古老,最稳定以及效率高的收集器,但可能会产生较长的停顿,只使用一个线程去回收。
- ParNew收集器,ParNew收集器其实就是Serial收集器的多线程版本。
- Parallel收集器,Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。
- Parallel Old收集器,Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程“标记-整理”算法
- CMS收集器,CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它需要消耗额外的CPU和内存资源,在CPU和内存资源紧张,CPU较少时,会加重系统负担。CMS无法处理浮动垃圾。CMS的“标记-清除”算法,会导致大量空间碎片的产生。
- G1收集器,G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
GC最基础的算法有三种:
- 标记 -清除算法
- 复制算法
- 标记-压缩算法
- 我们常用的垃圾回收器一般都采用分代收集算法(其实就是组合上面的算法,不同的区域使用不同的算法)。
具体:
- 标记-清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
- 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
- 标记-压缩算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
- 分代收集算法,“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
stackoverflow错误主要出现:
- 在虚拟机栈中(线程请求的栈深度大于虚拟机栈锁允许的最大深度)
permgen space错误(针对jdk之前1.7版本):
- 大量加载class文件
- 常量池内存溢出
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)