JVM
什么是JVM?
Java Virtual Machine的缩写,也就是Java虚拟机,是一个可以执行Java字节码的虚拟机进程,是Java语言实现跨平台性最关键的一部分。
你是怎么理解JVM的?
我认为JVM可以分为三部分,一是类加载器,二是运行时数据区,三是执行引擎。
JVM有什么特点?
- 一次编译,到处运行
- 自动内存管理
- 自动垃圾回收功能
Java代码是怎么执行的?
- JVM先通过前端编译器将.java的源文件编译成.class的字节码文件
- 再通过JIT编译器把字节码中的字节码指令编译成机器指令
JVM是基于什么架构实现的?
Java编译器输入的指令流大多数是基于栈的指令集架构来实现的,而另外一部分则是基于寄存器的指令集架构来实现的。
你还知道哪些虚拟机?
- HotSpotVM,Java的默认虚拟机,在服务器、桌面、移动端、嵌入式都有应用,比较有名的就是它的热点代码探测技术。
- BEA的JRockit,它专注于服务器端应用,大量的行业基准测试显示,JRockit JVM是世界上最快的JVM。
- IBM的J9,J9市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM,广泛用于IBM的各种Java产品。
什么是热点代码探测技术?
- 通过计数器找到最具编译价值的代码,触发即时编译缓存起来或栈上替换
- 通过编译器与解释器协同工作,在最优化的响应时间与最佳的执行性能中取得平衡
JVM生命周期
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的。
程序开始执行时它才运行,程序结束时它就停止。
有如下的几种情况:
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
- 某线程调用Runtime类或system类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作。
- 除此之外,JNI(Java Native Interface)规范描述了用JNI Invocation API来加载或卸载 Java虚拟机时,Java虚拟机的退出情况。
类加载器
类加载器的作用?
类加载器负责从文件或者网络中将Class文件加载到JVM内部,之后会放在方法区中。
类的加载过程
主要分为三个阶段,加载、链接、初始化。
它们都做了什么?
加载(Loading)
当加载一个类时,首先会通过这个类的全限定名,获取定义此类的二进制字节流,再将这个字节流所代表的静态存储结构,转化为方法区的运行时数据结构,之后会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口
链接(Linking)
链接又分为三个阶段,分别是验证、准备、解析
验证(Verify)
主要为了确保Class文件的字节流中,包含的信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
准备(Prepare)
为类变量分配内存并且设置该类变量的默认初始值,即零值。
解析(Resolve)
将常量池内的符号引用转换为直接引用
什么是符号引用
符号引用就是用一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。
什么是直接引用
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化(Initialization)
初始化阶段就是执行类构造器方法<clinit>()
的过程,<clinit>()
需要通过jclasslib查看,这个方法主要是由javac编译器收集的所有类变量的赋值动作和静态代码块中的语句组成。
类加载器有几种?
引导类加载器(启动类加载器,Bootstrap ClassLoader)
这个类加载器是由C/C++语言实现的,嵌套在JVM内部,没有父加载器,它用来加载Java的核心库(JAVAHOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
扩展类加载器(Extension ClassLoader)
由Java语言编写,父类加载器为启动类加载器,从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
系统类加载器(应用程序类加载器,AppClassLoader)
由Java语言编写,父类加载器为扩展类加载器,它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库,是程序中默认的类加载器,一般来说,Java应用的类都是由它来加载的。
双亲委派机制
- 如果一个类加载器收到了类加载请求,它会把这个请求委托给父类的加载器去执行,然后再自己加载;
- 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,最终将请求到顶层的启动类加载器;
- 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
举例
假设我们需要使用SPI接口,双亲委派机制会从系统类加载器依次请求到引导类加载器,而SPI接口属于核心rt.jar包中的API,所以最后会由引导类加载器加载,而接口需要实现还需要一些具体的实现类,这些具体的实现类则存在于第三方的jar包,例如jdbc.jar,它不属于核心jar包,所以就需要通过反向委派,最终由系统类加载器加载,而系统类加载器是由当前线程的上下文类加载器获取到的,所以jdbc.jar包里面的API最终是由线程的上下文类加载器来加载到的。
双亲委派的优势
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
沙箱安全机制
自定义一个String类,在加载它的时候,发现还是会使用引导类加载器加载,主要是因为引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以实现对java核心源代码的保护,被称为沙箱安全机制。
如何判断两个class对象是否相同
- 类的完整类名必须一致,包括包名。
- 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。
类的主动使用和被动使用
Java程序对类的使用方式分为:主动使用和被动使用。
主动使用,又分为七种情况:
- 创建类的实例
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(比如:Class.forName(“com.indi.Test”))
- 初始化一个类的子类
- Java虚拟机启动时被标明为启动类的类
- JDK7开始提供的动态语言支持:
java.lang.invoke.MethodHandle
实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化。
运行时数据区
由以下几部分组成
运行时数据区,是否存在Error和GC?
运行时数据区 | 是否存在Error | 是否存在GC |
---|---|---|
程序计数器 | 否 | 否 |
虚拟机栈 | 是 | 否 |
本地方法栈 | 是 | 否 |
方法区 | 是(OOM) | 是 |
堆 | 是 | 是 |
PC寄存器
什么是PC寄存器?
也叫程序计数器,记录了程序内部的运行流程和跳转顺序。
为什么要使用PC寄存器存储字节码指令地址呢?
因为CPU需要不停的切换各个线程,切换回来以后,就得知道从哪继续执行,所以就需要使用PC寄存器来记录程序执行的位置。
PC寄存器为什么被设定为私有的?
为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法就为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,即使CPU不停地做任务切换,导致线程中断或恢复,也不会出现相互干扰的情况。
虚拟机栈
你说一下Java虚拟机栈
每个线程在创建时都会创建一个虚拟机栈,其内部保存的是一个个的栈帧(Stack Frame),里面对应着一次次的Java方法调用,相对应的是一次次的入栈出栈操作。
栈的作用
主要负责Java程序的运行,存储声明的变量(局部变量表、操作数栈、动态链接、方法出口等)。
栈的生命周期
随线程的开始和结束而创建和销毁。
栈的特点
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM直接对Java栈的操作只有两个,就是对栈帧的入栈和出栈,遵循“先进后出”/“后进先出”原则。
是线程私有的,对于栈来说不存在垃圾回收的问题(但是存在栈溢出的问题)
栈的运行原理
如果当前正在执行的方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使前一个栈帧成为当前栈帧。
什么是栈帧
栈帧是一个内存区块、数据集,维系着方法执行过程中的各种数据信息,栈中的数据都是以栈帧(Stack Frame)的格式存在。
栈帧的特点
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。
只有当前正在执行的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
栈帧的内部结构
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(又称表达式栈)
- 动态链接(Dynamic Linking)(又称指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(又称方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表
局部变量表(local variables)主要用于存储方法参数和定义在方法体内的局部变量,包括8种基本数据类型、对象引用(reference),以及returnAddress类型。
局部变量表的特点
-
由于局部变量表是建立在线程的栈上,是线程的私有数据,所以它是线程安全的
-
局部变量表是以数组的形式来实现的,在编译期就确定了数组的长度,也即局部变量表的容量大小,在方法运行期间不会改变局部变量表的大小。
-
方法嵌套调用的次数由栈的大小决定
-
局部变量表中的变量只在当前方法调用中有效。当方法调用结束后,随着栈帧的销毁,局部变量表也会随之销毁。
-
局部变量表中的变量是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
静态变量与局部变量的区别
成员变量:在使用前都经历过默认初始化赋值
- 静态变量(static修饰)
- Linking的prepare阶段:给类变量默认赋值
- Initialization阶段:给类变量显式赋值以及静态代码块赋值
- 实例变量(非static修饰)
- 随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
局部变量:在使用前,必须进行显式赋值,否则编译不通过。
操作数栈Operand Stack
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储结构。
操作数栈的特点
操作数栈是以数组的形式来实现的,它的数组长度在编译期就确定了,在方法执行过程中,它会根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
栈顶缓存技术Top Of Stack Cashing
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接
如果被调用的方法在编译期无法被确定下来,只能在程序运行期间,将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
- 静态方法、私有方法、final方法,这些方法都不可重写
- 如果通过this的方式调用本类中其他重载的构造器了,这时候显然也是非常确定调的是哪一个构造器方法,所以构造器也是
- 假设父类有一个方法A,子类重写了方法A,在子类重写的方法A中,显式的通过super调用父类的方法A,这个时候也可以非常确定调用的是哪一个方法,所以父类方法也是
虚方法
没法在编译器期间确定下来的方法,这样的方法称为虚方法
普通调用指令
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用
<init>
方法、私有及父类方法,解析阶段确定唯一方法版本 - invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
动态调用指令
- invokedynamic:动态解析出需要调用的方法,然后执行
动态类型语言和静态类型语言
动态类型语言和静态类型语言两者的区别就在于,对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。
说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
Java 语言中方法重写的本质
- 当我们去调用一个对象的方法的时候,首先我们会将对象压入操作数栈,然后根据字节码指令(通常都是invokevirtual),来寻找这个对象的实际类型,记作C
- 接下来如果在C中能找到与常量池里名称、描述都相符的方法,则进行访问权限校验,如果有权限则把它转化为直接引用,正常结束;如果没权限,就会返回java.lang.IllegalAccessError非法访问异常
- 如果在C中没找到与常量池里名称、描述都相符的方法,那就会依次向上找C的父类,父类继续按照第二步执行,
- 如果最终都没有找到合适方法的话,那就说明是实现的接口中的方法了,因为接口的方法没有重写,所以实际调用的是一个抽象方法,最终会抛出java.lang.AbstractMethodError异常。
方法的调用
虚方法表
在面向对象的编程中,会很频繁的使用到动态分派(invokevirtual),如果在每次动态分派的过程中,都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表
(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
虚方法表是什么时候被创建的呢?
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
方法返回地址
存放调用该方法的pc寄存器的值。
正常完成出口
执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
- 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
- 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),
- lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn(引用类型)
- return(void方法,构造器、静态代码块使用)
异常完成出口
在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
java.lang.IllegalAccessError不兼容
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
java.lang.StackOverflowError 栈溢出
如果采用固定大小的Java虚拟机栈,一旦线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出StackOverflowError栈溢出异常。
举例栈溢出的情况?(StackOverflowError)
通过 -Xss设置栈的大小
调整栈大小,就能保证不出现溢出么?
不能保证不溢出
分配的栈内存越大越好么?
不是,一定时间内降低了OOM概率,但是会挤占其它的线程空间,因为整个空间是有限的。
垃圾回收是否涉及到虚拟机栈?
不会
方法中定义的局部变量是否线程安全?
具体问题具体分析
本地方法
什么是本地方法
本地方法(Native Method)就是指非Java的方法,是为了融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。
native关键字
public native void Native1(int x);
标识符native可以与其它java标识符连用,abstract除外
本地方法的特点
- 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
- 它甚至可以直接使用本地处理器中的寄存器
- 直接从本地内存的堆中分配任意数量的内存。
为什么要使用本地方法
- jre与底层系统的交互
- 使用一些Java语言本身没有提供封装的操作系统的特性
本地方法栈
用于管理本地方法的调用
本地方法栈的特点
- 线程私有
- 允许被设置成固定或者是可动态扩展的内存大小(在内存溢出方面与虚拟机栈是相同的)
- 并不是所有的JVM都支持本地方法
本地方法栈的执行流程
本地方法栈中负责登记本地方法,在执行引擎执行时加载本地方法库。
堆
在 java 虚拟机启动时创建的,几乎所有对象实例都在此创建,所以经常发生垃圾回收操作
堆的特点
-
是被线程共享的一块内存区域
-
堆的内存大小是可以调节的
-
堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的。
-
还可以划分线程私有的缓冲区TLAB
堆空间是怎么划分的?
Java 7及之前
- 新生代+老年代+永久代
Java 8及之后
- 永久代更换为元空间,其它保持不变
堆空间的比例是怎么分的
新生代:老年代 - > 1 : 2
新生代都有哪几个区
伊甸园区,两块大小相同的幸存者区(又称为幸存者0区,幸存者1区(有时也叫做from区、to区),空的是to区。
新生代的比例是怎么分的?
Eden:From:to -> 8:1:1
如何修改新生代与老年代在堆中的占比
-XX:NewRatio=4
表示新生代占1,老年代占4,新生代占整个堆的1/5
如何修改年轻代中 Eden区与 Survivor(from/to)区的比例
-XX:SurvivorRatio=8
表示Eden跟Survivor的比例是8:1:1
如何修改新生代的大小
-Xmn
设置年轻代大小, 等价于 -XX:NewSize
和 -XX:MaxNewSize
, 此参数一般使用默认值,
什么样的对象会被存到新生代?
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 生命周期短的,及时回收即可
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
你说一下对象的分配过程
我们创建的对象,一般都是存放在Eden区,随着对象的不断进入,可能会把伊甸园区放满,一旦伊甸园区放满了,就会触发垃圾回收机制,我们将它称之为YGC/MinorGC,是专属于新生代的垃圾回收。
-
进行GC的时候将会停止用户线程(STW),然后GC开始判断伊甸园区哪些对象属于垃圾,哪些不属于垃圾,进行完GC之后,以下图为例,红色的属于垃圾,将会被清除,清除之后空间将会得到释放,绿色的不属于垃圾,因为它们还需要继续使用,它们会提升到幸存者区,此时幸存者区都是空的,所以会将他们先放到幸存者0区,上面标注的数字1,代表年龄计数器,每个对象都会分配一个,从伊甸园区提升到幸存者0区的对象,会让它们的年龄计数器+1,此时的S1变成了Survivor to区。
-
接着Eden区继续存放对象,当Eden区再次存满的时候,又会触发YGC/MinorGC操作,此时的GC会对Eden和S0中的对象进行判断,把存活的对象放到S1,同时让它们的年龄计数器 + 1,此时的S0就空了,会变成新的Survivor to区,而S1则变成了Survivor from区,from区跟to区,会随着上述的操作,不断的在S0与S1之间切换位置。
-
我们继续进行对象生成和垃圾回收,当Survivor中对象的年龄计数器达到15的时候,将会触发一次 Promotion晋升的操作,会让该对象晋升到Old,晋升到Old之后则不会再考虑年龄计数器了。
对象分配的特殊情况
- YGC之后,发现对象比Eden区的空间大,Eden区放不下,将会考虑一步到位,直接将其放入Old区,如果Old区也放不下,将会进行Full GC/Major GC,如果还是放不下,将会OOM。
- 再说Survivor区,YGC之后,发现对象比Survivor to区的空间大,Survivor to区放不下,也会直接将其放入Old区,还有对象存活超过阈值,也会将其放到Old区。
- 以上假设,都是建立在不允许虚拟机动态调整新生代与老年代的大小之上。
Survivor区满了之后会怎么样?
Survivor区满了之后,不会触发YGC/MinorGC操作,但是会触发一些特殊的规则,这些规则可以让其中的对象直接晋升到Old
最终Survivor区的GC,会等Eden区满了之后,触发YGC/MinorGC的时候,跟着Eden区一起被回收。
新生代为什么要采用复制算法
为了解决碎片化的问题
谈谈垃圾回收
JVM调优的一个环节,就是垃圾回收,我们需要尽量避免垃圾回收,因为在垃圾回收的过程中,容易出现STW(暂停用户线程)的问题,会对程序性能产生一定的影响。
GC的工作机制
JVM在进行GC时,并非每次都对(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是新生代。
按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Young GC/Minor GC):只针对新生代(Eden、S0、S1)的垃圾收集
- 老年代收集(Old GC/Major GC):只针对老年代的垃圾收集
- 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
- 混合收集(Mixed GC):负责整个新生代以及部分老年代的垃圾收集。
- 目前,只有G1垃圾回收器会有这种行为
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
新生代GC(Young GC/Minor GC)的触发机制
- Eden区满了之后就出触发Minor GC
- 因为Java对象大多都具备 朝生夕灭 的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代GC(Major GC/Full GC)的触发机制
- 老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
- 出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)
- Major GC的速度一般会比Minor GC 慢10倍以上,STW的时间更长,如果Major GC后,内存还不足,就会出现OOM
(Full GC)的触发机制
触发Full GC执行的情况有以下五种:
- 调用
System.gc()
时,系统建议执行Full GC - 老年代空间不足
- 方法区空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、S0(From Space)区向S1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且且老年代因空间不足也无法存放
触发OOM之前,一定进行过一次Full GC,因为只有在老年代空间不足的时候,才会出现OOM异常
为什么要把Java堆分代?不分代就不能正常工作了吗?
其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
内存分配策略
如果对象在Eden出生并经过第一次Minor GC后仍然存活,还能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,每个JVM、每个GC都有所不同)时,就会被晋升到老年代
对象晋升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold
来设置
针对不同年龄段的对象分配原则如下所示:
-
优先分配到Eden
- 在放对象时,我们无法判断它是否为朝生夕死的,所以就先放到Eden区
-
大对象直接分配到老年代
- 大对象通常指的是需要一个比较长的连续的内存空间的对象,类似开发中比较长的字符串或者数组,
- Eden区放不下的对象会直接存入老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也会很快被回收,但是因为老年代触发Major GC的次数比 Minor GC要少,因此回收起来会慢一些
- 在实际开发中,尽量避免程序中出现过多的大对象
-
长期存活的对象分配到老年代,降低GC的频率
- 指的是超过阈值的对象
-
动态对象年龄判断
- 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象,可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
-
空间分配担保
-
-XX:HandlePromotionFailure
-
Eden的对象在GC之后,假设有大量的对象都是是存活的,甚至全部存活,因为Survivor比较小,无法容纳下Eden转过来的所有对象,此时就需要老年代进行空间分配担保,将Survivor无法容纳的对象,存放到老年代中,但是有一个前提,那就是老年代要有最够的空间容纳这些对象。
-
堆空间都是共享的么?
不一定,因为还有TLAB这个概念,在堆中划分出一块区域,为每个线程所独占
为什么要有TLAB?
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的,可以使用加锁等机制解决,但是会影响分配速度,而使用TLAB不仅可以避免这一系列的线程安全问题,同时还能够提升内存分配的吞吐量。我们将这种内存分配方式称之为快速分配策略。
什么是TLAB(Thread Local Allocation Buffer)
从内存模型的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内,默认是开启的。
TLAB分配过程
对象首先是通过TLAB开辟空间,如果不能放入,那么需要通过Eden来进行分配
堆是分配对象的唯一选择么?
通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
没有发生逃逸的对象,则可以分配到栈上
使用逃逸分析,编译器可以对代码做如下优化
- 栈上分配
- 同步省略
- 分离对象或标量替换
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步代码块的时候,JIT编译器可以借助逃逸分析,来判断同步代码块所使用的锁对象是否只能够被一个线程访问,而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
分离对象或标量替换
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把对象分解成若干个成员变量的构成。这个过程就是标量替换。
逃逸分析的不足
无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
方法区
方法区的特点
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,并且它实际的物理内存空间中和Java堆区一样,都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以设置成固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space 或者java.lang.OutOfMemoryError:Metaspace
- 加载大量的第三方的jar包
- Tomcat部署的工程过多(30~50个)
- 大量动态的生成反射类
- 加载大量的第三方的jar包
- 关闭JVM就会释放这个区域的内存。
- 说的简单一点,方法区就相当于是接口,而永久代跟元空间就是它的实现类。
HotSpot中方法区的演进
在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
- JDK 1.8后,元空间存放在堆外内存中
为什么永久代要被元空间替代?
元空间和永久代之间最大的区别在于:元空间使用的是本地内存,而不是虚拟机内存,最大值没有限制,仅受本地内存限制,只要有就可以使用。还有以下两点:
-
很难为永久代设置空间大小
在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。
比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断的加载很多类、jar包、方法,常量池的大小会比较大,所以导致我们不太确定该开辟多大的永久代,首先排除默认值,因为默认值也不算大,再如果设置的永久代空间比较小,那就很容易出现Full GC,出现Full GC就会导致STW的时间和次数就会比较多,对程序的性能有一定影响,还有如果进行完Full GC之后,它还没有被及时回收,这些类还需要用,那就会出现OOM,如果分配大了,那就有点浪费。所以为永久代设置大小很难确定。
-
对永久代进行调优很困难
- 主要是为了降低Full GC
设置方法区的大小
JDK7及以前
-XX:Permsize
设置永久代初始分配空间。默认值是20.75M-XX:MaxPermsize
永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
JDK8以后
-XX:MetaspaceSize
设置元空间的初始大小,Windows下默认值是21M-XX:MaxMetaspaceSize
设置元空间的最大值,默认值是-1,也就是本地内存的最大值
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元空间发生溢出,虚拟机一样会抛出异常OOM
你是怎么设置这两个参数的
将-XX:MetaspaceSize
设置为一个相对较高的值,而-XX:MaxMetaspaceSize
则一般维持默认值,即使用本地内存即可。
为什么这么设置?
以Windows下的初始大小21M为例,对于一个64位的服务器端JVM来说,这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活)然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,会适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,所以为了为了避免这种情况的发生,我会选择将它设置为一个相对较高的值。
方法区的内部结构
由类型信息、域信息、方法信息、运行时常量池、静态变量、即时编译器编译后的代码缓存等组成。
类型信息
对于加载的每个类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)
- 这个类型的修饰符(public,abstract,final的某个子集)
- 这个类型直接接口的一个有序列表
域信息
JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)
方法(Method)信息
JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:
-
方法名称
-
方法的返回类型(或void)
-
方法参数的数量和类型(按顺序)
-
方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
-
方法的字节码(bytecodes)、操作数栈、局部变量表及形参的大小(abstract和native方法除外)
-
异常表(abstract和native方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
non-final的类变量
静态变量和类关联在一起,随着类的加载而加载,他们成为类数据在逻辑上的一部分
类变量被类的所有实例共享,即使没有类实例时,也可以访问它
/**
* non-final的类变量
*/
public class MethodAreaTest {
public static void main(String[] args) {
Order order = null;
order.hello();
System.out.println(order.count);
}
}
class Order{
public static int count = 1;
public static final int number = 2;
public static void hello(){
System.out.println("hello");
}
}
如上代码所示,即使我们把order设置为null,也不会出现空指针异常
全局常量
全局常量就是使用 static final
进行修饰
对于声明为static的常量,在编译的时候就为其赋值了
运行时常量池 VS 常量池
- 运行时常量池存放在方法区中
- 常量池则被存放在字节码文件中
- 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
- 要弄清楚方法区的运行时常量池,需要理解清楚classFile中的常量池。
常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池,其中包含了各种字面量以及对类型、域和方法的符号引用
字面量就是等号右边中""里的具体值
为什么需要常量池
java源文件中的类、接口,编译后会产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大,以至于不能直接存到字节码里,把它存到常量池中,让字节码只存储指向常量池的引用,就可以让字节码文件省去很多空间。
常量池中有什么
- 数量值
- 字符串值
- 类引用
- 字段引用
- 方法引用
常量池可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型
常量池,存放的各种字面量与符号引用,将在类加载后存放到方法区的运行时常量池中。
什么是运行时常量池
运行时常量池就是常量池在运行时的一种表现形式
运行常量池的特点
-
运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
-
JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的,是从1到count-1。
-
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。
-
运行时常量池,相对于Class文件常量池的另一重要特征是:具备动态性。
- 以String为例,它有一个
intern()
方法,这个方法我们就要考虑,如果String在常量池中没有,就要放一个常量String.intern()
到常量池当中
- 以String为例,它有一个
静态变量存放在那里?
静态引用对应的对象实体始终都存在堆空间
方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
对象实例化内存布局与访问定位
对象有几种创建方式
-
new
-
Class的
newInstance()
-
Constructor的
newInstance(XXX)
-
使用
clone()
-
使用反序列化
-
第三方库 Objenesis
创建对象的步骤
-
判断对象对应的类是否加载、链接、初始化
-
为对象分配内存
-
处理并发问题
-
初始化分配到的空间
-
设置对象的对象头
-
执行init方法进行初始化
对象实例化的过程
- 加载类元信息
- 为对象分配内存
- 处理并发问题
- 属性的默认初始化(零值初始化)
- 设置对象头的信息
- 属性的显式初始化、代码块中初始化、构造器中初始化
对象内存布局
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?
有两种访问方式
句柄访问
句柄访问就是说栈的局部变量表中,记录的对象的引用,然后在堆空间中开辟了一块空间,也就是句柄池
优点
reference中存储固定的句柄地址,如果堆空间的对象被移动(垃圾收集时移动对象很普遍)时只需改变句柄中实例数据指针即可,reference本身不需要被修改
直接指针(HotSpot采用)
直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据
直接内存
直接内存是在Java堆外的、直接向系统申请的内存区间。
非直接缓存区和缓存区
原来采用BIO的架构
使用Java程序访问物理内存上的文件时,比如说我们要读这个文件,需要从用户态切换成内核态, 需要从虚拟机的内存中读取具体地址上的数据,而这个地址还需要从内核地址上获取,然后才能对应到物理磁盘上。
DirectBuffer
NIO的方式使用了缓存区的概念,就不会有用户态和内核态这两种状态以及中间copy的过程了,直接就存在物理映射文件中
如何设置直接内存
直接内存大小可以通过-XX:MaxDirectMemorySize
设置,如果不指定,默认与堆的最大值-Xmx
参数值一致
执行引擎
你说说执行引擎
执行引擎就是将高级语言翻译为机器语言的翻译
执行引擎的作用
将字节码指令解释/编译为对应平台上的本地机器指令
执行引擎里面都有什么
里面包括解释器、及时编译器、垃圾回收器
执行引擎的工作流程
- 执行引擎在执行的过程中要执行什么样的字节码指令完全依赖于PC寄存器。
- 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
- 当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用,准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
从外观上来看,Java虚拟机的执行引擎输入,输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行过程。
什么是解释器(Interpreter)
当Java虚拟机启动时,解释器会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
什么是JIT编译器
就是完成了直接将源代码编译成和本地机器平台相关的机器语言的编译器。
为什么Java是半编译半解释型语言
因为JDK1.0时代,Java语言主要以解释执行为主。再后来,Java也发展出了可以直接生成本地代码的编译器。现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
既然已经有了JIT编译器,为什么还要使用解释器呢?
首先要明确,当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。
而编译器要想把代码编译成本地代码,则需要一定的执行时间。但编译为本地代码后,执行效率高。
对于那些看中启动时间的应用场景而言,采用解释器与即时编译器并存的架构是为了来换取一个平衡点。
同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”。
JRockit VM就没有解释器,不是一样挺快的吗?
那是因为JRockit只部署在服务器上,一般已经有时间让它进行指令编译的过程了,对于响应来说要求不高,等及时编译器的编译完成了,就会提供更好的性能。
什么是热点代码
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”
热点探测技术
HotSpot VM所采用的热点探测方式是基于计数器的热点探测,它会为每一个方法都建立两个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
- 方法调用计数器用于统计方法的调用次数
- 回边计数器则用于统计循环体执行的循环次数
HotSpotVM 可以设置程序执行方法
命令行设置
-Xint
:完全采用解释器模式执行程序;-Xcomp
:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行-Xmixed
:采用解释器+即时编译器的混合模式共同执行程序。
HotSpotVM中 JIT 分类
在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,我们简称为C1编译器 和 C2编译器。
- -client:指定Java虚拟机运行在Client模式下,并使用C1编译器
- C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
- -server:指定Java虚拟机运行在server模式下,并使用C2编译器(64位操作系统默认是server模式)
- C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。(使用C++编写的)
StringTable
String的特性
- String:字符串,使用一对 ”” 引起来表示
String s1 = "qaq"
; // 字面量的定义方式String s2 = new String("aqa")
;
- String声明为final的,不可被继承
- String实现了Serializable接口:表示字符串是支持序列化的。还实现了Comparable接口:表示String可以比较大小
- String在JDK8及以前内部是用
final char[] value
存储字符串数据。JDK9时改为final byte[] value
为什么JDK9改变了结构
官方从许多不同的应用程序收集的数据中发现,字符串是堆使用的主要组成部分,但是大多数字符串对象只包含拉丁字符,而这些字符只需要一个字节的存储空间,这就意味着这些字符串对象的内部char[]有一半的空间将不会使用。
官方将字符串的内部改为byte[]+一个encoding-flag字段。新的String类将根据字符串的内容,来选择用哪种方式存储,如果是存储编码为ISO-8859-1/Latin-1就用一个byte去存,如果是UTF-16,则还是用两个字符去存。
为什么StringTable要从永久代搬到堆空间里呢?
因为只有在老年代或者永久代不足时,才会触发Full GC对永久代进行回收,这就导致永久代的回收效率很低,从而StringTable的回收效率也不高。而实际开发中会有大量的字符串被创建,回收效率低,会导致永久代内存不足,而放到堆里,能及时回收内存。
*String的不可变性
String:代表不可变的字符序列。简称:不可变性。
- 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值。
- 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
- 当调用String的
replace()
方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。
通过字面量的方式(区别于new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。
相关的面试题
public class StringExer {
String str = new String("good");
char [] ch = {'t','e','s','t'};
public void change(String str, char ch []) {
str = "test ok";// 改的是形参,而不是上面的局部变量
ch[0] = 'b';
}
public static void main(String[] args) {
StringExer ex = new StringExer();
ex.change(ex.str, ex.ch);
System.out.println(ex.str);// good
System.out.println(ex.ch);// best
}
}
String的内存分配在什么位置?
在字符串常量池中
什么是字符串常量池
就类似一个Java提供的一个系统级别的缓存,是为了让它在运行过程中速度更快、更节省内存所提供的。
怎么才能把字符串存到字符串常量池中?
- 直接以字面量的方式定义String
- 使用
String.intern()
字符串常量池在什么位置
Java 6及以前,字符串常量池存放在永久代
Java 7中 Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内
Java 8中,字符串常量池还是在堆内
Java 7为什么要把字符串常量池从永久代调整到堆中
- 永久代的默认空间比较小
- 永久代垃圾回收频率低
你怎么理解字符串拼接
- 常量与常量的拼接结果在常量池,原理是编译期优化
- 常量池中不会存在相同内容的变量
- 只要其中有一个是变量,结果就在堆中。变量拼接的原理是StringBuilder
- 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入常量池,并返回此对象地址
字符串拼接的底层是什么
在JDK5之后,使用的是StringBuilder,在JDK5之前使用的是StringBuffer
- 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,将其存到字符串常量池中
- 针对于final修饰的类、方法、基本数据类型、引用数据类型,能使用final就使用上final
字符串拼接与append哪个更好
通过StringBuilder的append()
方式添加字符串的效率,要远远高于String的字符串拼接方法
为什么?
- StringBuilder的
append()
的方式,自始至终只创建过一个StringBuilder的对象 - 而字符串拼接的方式,需要创建很多StringBuilder对象和调用
toString()
时候创建的String对象 - 内存中由于创建了较多的StringBuilder和String对象,内存占用过大,如果进行GC将会耗费更多的时间
你认为应该怎么改进
-
我们使用的是StringBuilder的空参构造器,默认的字符串容量是16,然后将原来的字符串拷贝到新的字符串中, 我们也可以默认初始化更大的长度,减少扩容的次数
-
在实际开发中,如果能够确定前前后后需要添加的字符串,不高于某个限定值highlevel,那么建议使用如下的构造器实例化
-
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
intern()
intern()的作用
intern()
方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。
*面试题
new String(“ab”)会创建几个对象
public class StringNewTest {
public static void main(String[] args) {
String str = new String("ab");
}
}
编译成字节码文件之后,发现会创建两个对象,会在常量池中生成qwe
new String(“a”) + new String(“b”) 会创建几个对象
public class StringNewTest {
public static void main(String[] args) {
String str = new String("a") + new String("b");
}
}
字节码文件,会创建6个对象,不会在常量池中生成ab
引用类型的变量实际保存的都是地址
*intern的面试题
JDK6中
JDK6的常量池是存在永久代里
public class StringIntern {
public static void main(String[] args) {
// 首先new String创建的对象会存入堆空间,然后("1")创建的对象会存入字符串常量池,
// 最后会将堆空间的对象地址返回给s
String s = new String("1");
// intern()会到字符串常量池中查找是否有"1",而“1”在一开始创建s的时候,就存入
// 字符串常量池了,所以intern()不会起什么作用
s.intern();
// s2用的就是字符串常量池中存的("1")的地址
String s2 = "1";
// 结果在jdk6/7/8中均为false
System.out.println(s == s2);
// s3变量记录的地址为:new String("11");这个操作不会在字符串常量池中创建"11"
String s3 = new String("1") + new String("1");
// 在字符串常量池中创建一个"11"的对象
s3.intern();
// s4使用的"11"的地址
String s4 = "11";
System.out.println(s3 == s4); //jdk6:false
}
}
JDK7中
jdk7中有个变化,那就是把常量池放在堆当中了
public class StringIntern {
/**
* intern面试题,在单元测试中测试这个例子,后办单jdk8提示为false
*/
public static void main(String[] args) {
// s3变量记录的地址为:new String("11");这个操作不会在字符串常量池中创建"11"
String s3 = new String("1") + new String("1");
// 此时字符串常量池中并没有创建"11",为了节省空间,创建了一个指向引用堆空间的new String("11")的地址
s3.intern();
// 给s4赋值字面量的时候,会去字符串常量池中找,结果发现字符串常量池的"11"指向的是的堆空间的new String("11")
// 这就相当于,s4也是指向的堆空间的new String("11")
String s4 = "11";
System.out.println(s3 == s4); //jdk7/8:true
}
}
简单拓展
/**
* jdk8
*/
@Test
public void test2(){
// 在堆空间创建的new String("11")
String s3 = new String("1") + new String("1");
// 在字符串常量池中生成的"11"对象
String s4 = "11";
// 发现字符串常量池中已经有"11"对象,所以intern()不起作用
String s5 = s3.intern();
System.out.println(s3 == s4); // false
System.out.println(s4 == s5); // true
}
总结
总结String的intern()
的使用:
JDK1.6中,将这个字符串对象尝试放入串池(字符串常量池)。
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,会把此对象复制一份,放入串池,并返回串池中的对象地址
JDK1.7起,将这个字符串对象尝试放入串池。
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份,放入串池,并返回串池中的引用地址
练习一
/**
* intern练习1:jdk6
*/
@Test
public void test3(){
// 在堆空间中new String("ab")
String s = new String("a") + new String("b");
// 在字符串常量池中创建"ab"的对象
String s2 = s.intern();
System.out.println(s2 == "ab"); // true
System.out.println(s =="ab"); // false
}
/**
* intern练习1:jdk8
*/
@Test
public void test3(){
// 在堆空间中new String("ab")
String s = new String("a") + new String("b");
// 将new String("ab")的引用存入常量池
String s2 = s.intern();
// 此时的常量池没有"ab",记录的是new String("ab")的引用,
// 这就意味着s2在字符串常量池存的地址是new String("ab"),它的值也是"ab"
System.out.println(s2 == "ab"); // true
// "ab" = s2,s2 = s.intern(),inter()指向的是new String("ab")
System.out.println(s =="ab"); // true
}
/**
* intern练习1扩展:jdk6/7/8
*/
@Test
public void test3(){
// 通过字面量的方式将“ab”存入常量池
String x = "ab";
// 在堆空间中new String("ab"),不会在常量池中创建"ab"
String s = new String("a") + new String("b");
// 到常量池中查找,发现已有“ab”,直接取出赋值给s2
String s2 = s.intern();
System.out.println(s2 == "ab"); // true
// "ab" = s2,s2 = s.intern(),inter()指向的是new String("ab")
System.out.println(s =="ab"); // false
}
练习二
/**
* inter练习2
*/
@Test
public void test4(){
// 在堆空间创建new String("ab");不会在字符串常量池中创建"ab"
String s1 = new String("a") + new String("b");
// 在常量池中创建了一个指向堆空间的new String("ab")的地址
s1.intern();
// 给s2赋值的时候,去常量池发现"ab"指向的是new String("ab")的引用
String s2 = "ab";
System.out.println(s1 == s2); //true
}
/**
* inter练习2扩展
*/
@Test
public void test5(){
// 在堆空间创建new String("ab");并在字符串常量池中创建"ab"
String s1 = new String("ab");
// 字符串常量池中已有"ab",所以不会起什么作用
s1.intern();
// 赋值字符串常量池的"ab"
String s2 = "ab";
System.out.println(s1 == s2); // false
}
intern的空间效率测试
对于程序中大量使用存在的字符串时,尤其存在很多重复的字符串时,使用intern()方法能够节省内存空间。
大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern() 方法,就会很明显降低内存的大小。
StringTable的垃圾回收
字符串常量池中的个数在100、10000的时候,并没有产生GC,而数据量达到100000的时候,进行了YGC,使得堆空间的字符串信息降了下去。
G1中的String去重操作
G1垃圾收集器,会针对堆中的数据进行去重,注意是堆中不是常量池,常量池中的数据本身就不会重复。
它是怎么实现的
- 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的string对象。
- 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的string对象。
- 使用一个hashtable来记录所有的被string对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
- 如果存在,string对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
- 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了。
如何开启
-XX:+UsestringDeduplication(bool)
:开启string去重,默认是不开启的,需要手动开启。-XX:+Printstringbeduplicationstatistics(bool)
:打印详细的去重统计信息-XX:+stringpeduplicationAgeThreshold(uintx)
:达到这个年龄的String对象被认为是去重的候选对象
垃圾回收
什么是垃圾?
内存中不再被使用的空间就是垃圾
为什么需要GC
对于高级语言来说,如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像家里有垃圾却从来不收拾一样。
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象。
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
你如何理解System.gc()
- 通过
System.gc()
,会显式触发Full GC - 不能保证立即生效
什么是内存泄漏
只有对象不会再被程序用到了,但是GC又不能回收它们的情况,叫内存泄漏
比如
1、单例模式,单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,会导致内存泄漏的产生。
2、一些提供close的资源未关闭导致内存泄漏,比如数据库连接、网络连接、IO。
什么是Stop The World
指的是GC事件发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应。
并发与并行
并发
并发不是真正意义上的“同时进行”,只是CPU把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
并行
当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行。
决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
并发与并行的对比
并发,指的是多个事情,在同一时间段内同时发生了。
并行,指的是多个事情,在同一时间点上同时发生了。
并发的多个任务之间是互相抢占资源的。并行的多个任务之间是不互相抢占资源的。
安全点与安全区域
安全点
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点(Safepoint)”。
如何选择安全点
选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。
为什么这么选
如果选的太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题
如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
-
抢先式中断:(目前没有虚拟机采用了)
首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
-
主动式中断:
设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制),如果是假的,则可以选择继续去执行
安全区域
安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。
安全区域是怎么执行的?
- 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程
- 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,没完成,线程则需要继续等待,直到收到可以安全离开Safe Region的信号为止
引用
谈谈强软弱虚引用?
- 强引用(StrongReference): 最传统的“引用”定义,是指在代码中普遍存在的引用赋值,类似
Object obj = new Object();
这种引用关系。无论任何情况下,只要强引用的关系还在,垃圾回收器就永远不会回收掉被引用的对象。- 强引用可以直接访问目标对象。
- 强引用所指向的对象在任何时候都不会被系统回收,JVM宁愿抛出OOM异常,也不会回收强引用所指向的对象。
- 强引用可能导致内存泄漏。
- 软引用(SoftReference):在系统将要发生内存溢出之前,会把软引用关联的对象列入回收范围进行第二次回收。如果这次回收后还没有足够的内存,才会抛出OOM。在GC时,内存充足即保留,内存不足即回收。通常会在缓存的场景下去使用。
- 弱引用(WeakReference):在系统GC时,不管堆空间使用是否充足, 都会回收掉被弱引用关联的对象。
- 虚引用(Phantom Reference):说白了,就是形同虚设,如果一个对象仅有虚引用,那么它就和没有任何引用一样,在任何时候都有可能被垃圾回收器回收,它的主要作用是监控对象回收的情况。虚引用必须和引用队列(ReferenceQueue)联合使用。
软引用和弱引用的应用场景
假如有一个应用需要读取大量的本地图片:
- 如果每次读取图片都从硬盘读取则会严重影响性能
- 如果一次性全部加载到内存中又可能造成内存溢出
此时使用软引用可以解决这个问题,比如说Mybatis的缓存技术,就大量使用了软引用。
设计思路是:用HashMap来保存图片的路径,和相应图片关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。
你知道弱引用的话,能谈谈WeakHashMap吗?
import java.util.WeakHashMap;
public class WeakHashMapDemo {
public static void main(String[] args) {
WeakHashMap<Integer, String> map = new WeakHashMap<Integer, String>();
Integer key = new Integer(2);
String value = "WeakHashMap";
map.put(key, value);
System.out.println(map);
key = null;
System.out.println(map);
System.gc();
System.out.println(map + "\t" + map.size());
}
}
结论:使用WeakHashMap,拿来做缓存,一旦GC,马上就会将其移除,这样内存空间就腾出来了,发生OOM的概率将会大大降低。
ReferenceQueue有什么作用?
ReferenceQueue是用来配合引用工作的,没有ReferenceQueue一样可以运行。
创建引用的时候可以指定关联的引用队列,当GC释放对象内存的时候,会将引用加入到引用队列,这就意味着这个引用所指向的对象已经被回收,这相当于是一种通知机制。
终结器引用
它用于实现对象的finalize()
方法,也可以称为终结器引用
无需手动编码,其内部配合引用队列使用
在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象调用它的finalize()
方法,第二次GC时才回收被引用的对象
垃圾回收算法
GC的时候,JVM如何确定对象是垃圾?
有两种方式:引用计数算法和可达性分析算法。
引用计数算法
给对象添加一个引用计数器,每当有地方引用它时,计数器+1,每当它有引用失效时,计数器-1,当计数器的值为0时,就代表该对象无法再使用,需要被回收。它有一个致命缺点,就是无法处理循环引用,所以才导致Java没有使用这种算法。
什么是循环引用
p是一个引用,指向了next这个对象实体,而next中有一个属性又指向了下一个next实体,一直到最后一个next,最后一个next实体中有一个属性又调用了第一个next实体,里面相应的计数器都进行了增加。
当p的指针变为null时,内部的引用形成了一个循环,即使后面的next减了1之后变成1了,后面剩余的两个next的计数器还是1, 按理来说p的指针变为null之后,后面的几个对象也应该不要了,但是它们的计数器还是1,这就导致了他们无法被回收,所以说这也是一个内存泄漏的行为。
可达性分析算法
以根对象集合“GC Roots”作为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达,存活的对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链,如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
什么是GC Roots
是四种对象的根基合体,以这四种对象作为垃圾回收扫描的起始点
在Java中可以作为GC Roots对象的有:
- *虚拟机栈中引用的对象
- 比如:线程调用方法中使用的参数、局部变量表等。
- *本地方法栈内JNI(通常说的本地方法)引用的对象
- *方法区中类静态属性引用的对象
- 比如:Java类的引用类型静态变量
- *方法区中常量引用的对象
- 比如:字符串常量池(String Table)里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用
- 基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
JProfiler的GC Roots溯源
假设有一个对象,我们已经不打算用它了,当我们使用可达性分析的时候,发现它还是直接或间接的被GC Roots所关联着,所以就没办法GC,这就是Java中真正意义上的内存泄漏。
我们在实际的开发中,一般不会查找全部的GC Roots,可能只是查找某个对象的整个链路,称为GC Roots溯源,这个时候,我们就可以使用JProfiler
对象的finalization机制
对象被销毁之前的自定义处理逻辑
finalize()的特点
- 在对象被垃圾回收之前,总会先调用这个对象的
finalize()
方法。 finalize()
方法允许在子类中被重写,用于在对象被回收时进行资源释放。
你是怎么使用finalize()的
我不会主动调用某个对象的finalize()
方法,有以下3点原因
- 在
finalize()
时可能会导致对象复活。 finalize()
方法的执行时间是没有保障的,因为优先级比较低,即使主动调用该方法,也不会马上执行finalize()
没写好的话,会严重影响GC的性能。
对象的存活状态有哪几种
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在
finalize()
中复活。 - 不可触及的:对象的
finalize()
被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()
只会被调用一次。
finalize()对回收的影响
-
如果对象objA到GC Roots没有引用链,则进行第一次标记。
-
进行筛选,判断此对象是否有必要执行
finalize()
方法- 如果对象objA没有重写
finalize()
方法,或者finalize()
方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。 - 如果对象objA重写了
finalize()
方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()
方法执行。 finalize()
方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()
方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize()
方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()
方法只会被调用一次。
- 如果对象objA没有重写
JVM中有哪些垃圾回收算法
标记-清除算法(Mark-Sweep)
GC时,分为两个阶段,首先从引用根节点开始遍历,将所有被引用的对象标记出来,在对象的header中记录为可达对象,然后再从头到尾进行线性的遍历,如果发现哪个对象的header中没有被标记为可达对象,则将其回收。
标记-清除的缺点
效率不算高,在进行GC的时候,需要STW,用户体验较差,这种方式清理出来的空闲内存是不连续的,会产生内存碎片,需要维护一个空闲列表
复制算法(copying)
将活着的内存空间分为A、B区,每次只使用其中一个区,假设一开始对象都存在A区,在垃圾回收时,将A区的存活对象复制到B区,之后清除A区中的所有对象,最后完成垃圾回收
复制算法的优点
实现简单,运行高效,复制过去以后可以保证空间的连续性,不会出现“碎片”问题。
复制算法的缺点
需要两倍的内存空间,对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC还需要维护region之间对象的引用关系,时间开销也不小
标记-压缩算法(Mark-Compact)
第一阶段和标记清除算法一样,从根节点开始标记所有被引用的对象,第二阶段将所有的存活对象整理到内存的一端,按顺序排放。之后,清理边界外所有的空间。
标记-压缩优点
消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。消除了复制算法当中,内存减半的高额代价。
标记-压缩缺点
从效率上来说,标记-压缩算法要低于复制算法,移动对象的同时,如果对象被其他对象引用,还需要调整引用的地址,移动过程中,需要STW
分代收集算法
不同生命周期的对象可以采取不同的收集方式,以便提高回收效率,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
缺点
会产生STW,如果垃圾回收时间过长,STW会持续很久,将严重影响用户体验或者系统的稳定性。
增量收集算法
每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
分区算法
为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间(region),而不是整个堆空间,从而减少一次GC所产生的停顿。
垃圾回收算法和垃圾收集器的关系?
垃圾回收算法(引用计数/复制/标记清除/标记压缩)是内存回收的方法论,垃圾收集器就是算法的落地实现
垃圾回收器
如何评估GC的性能指标
- *吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间 = 程序的运行时间 + 内存回收的时间)
- 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
- *暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
- 收集频率:相对于应用程序的执行,收集操作发生的频率。
- *内存占用:Java堆区所占用的内存大小。
- 快速:一个对象从诞生到被回收所经历的时间。
垃圾回收器主要清理什么地方
方法区和堆
垃圾回收器是怎么清理堆的
- 频繁收集Young区
- 较少收集Old区
- 基本不收集Perm区(元空间)
垃圾回收的并发与并行
串行
一次只能有一条垃圾回收线程进行工作,垃圾回收线程执行时,会暂停用户线程,回收完,再启动用户线程。
并发
指用户线程与垃圾回收线程同时执行(但不一定是并行的,可能会交替执行),如CMS、G1
并行
指多条垃圾回收线程同时执行,但此时用户线程仍处于等待状态。如ParNew、Parallel Scavenge、Parallel Old;
JVM中有哪些垃圾收集器
串行垃圾收集器
-
有Serial、Serial old
-
为单线程环境设计,只使用一个线程进行垃圾回收,垃圾回收时会暂停所有的用户线程,不适合服务器环境。
并行垃圾收集器
- 有ParNew、Parallel Scavenge、Parallel old
- 多个GC线程同时工作,垃圾回收时会暂停所有的用户线程,适用于科学计算/大数据处理等弱交互场景
并发垃圾收集器
- 有CMS、G1
- 用户线程和垃圾收集线程同时执行(不一定是并行,可能交替执行),不需要停顿用户线程,互联网公司使用它较多,主要用于对响应时间有要求的场景
说说你了解的垃圾收集器
Serial
- Serial是Client模式下默认的年轻代垃圾收集器
- Serial在新生代采用了串行回收、复制算法、STW的机制
Serial Old
-
Serial Old是Client模式下默认的老年代垃圾收集器
-
Serial Old在老年代采取标记-压缩算法、STW的机制
-
它可以与新生代的Parallel Scavenge配合使用、还以可以作为老年代CMS收集器的后备垃圾收集方案
-
总结:Serial/Serial Old都是单线程的垃圾收集器,与其他收集器的单线程比,它们更加的简单高效,但是对于交互性较强的应用来说,这种垃圾收集器不太合适。
Serial参数设置
使用-XX:+UseSerialGC
参数可以指定年轻代和老年代都使用串行收集器
ParNew
- ParNew收集器是Serial收集器的多线程版本,除了执行方式换成了并行回收,ParNew也样采用了复制算法、STW的机制,是处理新生代的垃圾收集器。
- 它在老年代时,因为回收次数少,所以使用串行方式更节省资源(CPU并行需要切换线程,串行可以省去切换线程的资源),所以常用来与Serial Old进行组合,但是这个组合在JDK8中被声明为废弃,而在JDK9中则彻底移除了。
- 除了Serial Old以外,就只有CMS可以与其组合了。
ParNew参数设置
-XX:+UseParNewGC
手动指定使用ParNew收集器执行内存回收任务
Parallel Scavenge
- Parallel Scavenge的目标是达到一个可控制的吞吐量,也被称为吞吐量优先的垃圾收集器,它还可以动态的调整内存的分配,以达到程序最优的效果。
- Parallel Scavenge也采用了并行回收、复制算法、STW的机制,是处理新生代的垃圾收集器,也是Java8的默认垃圾收集器。
- 高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Old
- Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和STW机制,是处理老生代的垃圾收集器。
- Parallel收集器和Parallel Old的组合,在Server模式下的内存回收性能很不错。
Parallel参数配置
-XX:+UseParallelGC
手动指定年轻代使用Parallel并行收集器执行内存回收任务。
-XX:+UseParallelOldGC
手动指定老年代都是使用并行回收收集器。
- 分别适用于新生代和老年代。默认jdk8是开启的。
- 上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
-XX:ParallelGCThreads
设置年轻代并行收集器的线程数,最好与CPU数量相等,以免过多的线程数影响垃圾收集性能。
-
在默认情况下,当CPU数量小于8个,ParallelGCThreads的值等于CPU数量。
-
当CPU数量大于8个,ParallelGCThreads的值等于3+[5*CPU Count]/8]
-XX:MaxGCPauseMillis
设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。
- 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,垃圾收集器在工作时会调整Java堆的大小或者其他一些参数。
- 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端更适合Parallel。
- 该参数使用需谨慎。
-XX:GCTimeRatio
。垃圾收集时间占总时间的比例(=1 /(N+1)),用于衡量吞吐量的大小。
-
取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1%
-
与前一个
-XX:MaxGCPauseMillis
参数有一定矛盾性。暂停时间越长,Ratio参数就越容易超过设定的比例。
-XX:+UseAdaptiveSizepPolicy
设置Parallel Scavenge收集器具有自适应调节策略,默认是自动打开的。
-
在这种模式下,会自动调整年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数,以达到在堆大小、吞吐量和停顿时间之间的平衡点。
-
在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。
CMS
- 这是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
- CMS采用采用标记-清除算法,并且也会STW,是处理老年代的垃圾收集器
CMS的回收过程
- 初始标记(Initial-Mark):标记出GCRoots能直接关联到的对象,程序中所有的工作线程都将会因为STW机制而出现短暂的暂停。
- 并发标记(Concurrent-Mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与用户线程一起同时执行。
- 重新标记(Remark):修正并发标记阶段,因为用户线程与垃圾回收线程同时执行,导致的并发标记怀疑是垃圾的那些对象,这个阶段的停顿时间通常会比初始标记阶段长一些,但远比并发标记阶段的时间短。
- 并发清理(Concurrent-Sweep):将标记为已死亡的对象清除,释放内存空间,清除完之后,会存在内存碎片的问题,可以考虑用标记-压缩算法对内存进行整理,由于不需要移动对象,所以这个阶段也是可以与用户线程同时执行的。
CMS的优点
初始标记和重新标记的延迟很低,并发标记和并发清理都是并发的操作。
CMS的缺点
会产生内存碎片,对CPU资源非常敏感,无法处理浮动垃圾。
CMS参数设置
-XX:+UseConcMarkSweepGC
手动指定使用CMS收集器执行老年代内存回收任务。
- 开启该参数后会自动将
-XX:+UseParNewGC
打开。即:ParNew(Young区用)+CMS(Old区用)+Serial Old的组合。
-XX:CMSInitiatingOccupanyFraction
设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
-
JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以上版本默认值为92%
-
如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项可以有效降低Full GC的执行次数。
-XX:+UseCMSCompactAtFullCollection
用于指定在执行完Full GC后对内存空间进行压缩整理
- 以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
-XX:CMSFullGCsBeforeCompaction
设置在执行多少次Full GC后对内存空间进行压缩整理。
-XX:ParallelCMSThreads
设置CMS的线程数量。
- CMS默认启动的线程数是(ParallelGCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数。
- 当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
G1
-
G1是Java7之后引入的垃圾收集器,是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
-
它采用了全新的分区算法,将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。从分代上看,G1依然属于分代型垃圾回收器,但从堆的结构上看,它不要求各个区域都是连续的,也不再坚持固定大小和固定数量。
G1的优点
-
并行性与并发性
G1在回收期间,可以多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
-
分代收集
从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求区都是连续的,也不再坚持固定大小和固定数量。
-
空间整合
Region之间是复制算法(比如说将对象从Eden区复制到Survivor区),但整体上实际可看作是标记-压缩(Mark-Compact),因为这些操作都是在一块内存空间完成的,两种算法都可以避免内存碎片,有利于程序长时间运行。
-
能建立可预测的停顿时间模型
让使用者在一个长度为M毫秒的时间片段内,指定消耗在垃圾收集上的时间不得超过N毫秒,也就是实时的意思
G1的缺点
相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比CMS要高。
从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。
G1参数设置
-XX:+UseG1GC
:手动指定使用G1垃圾收集器执行内存回收任务-XX:G1HeapRegionSize
设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms-XX:+ParallelGcThread
设置STW时GC线程数的值。最多设置为8-XX:ConcGCThreads
设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。-XX:InitiatingHeapOccupancyPercent
设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。
G1的应用场景
面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分,而不是全部的Region的增量式清理,来保证每次GC停顿时间不会过长)。
用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
- 超过50%的Java堆被活动数据占用;
- 对象分配频率或年代提升频率变化很大;
- GC停顿时间过长(长于0.5至1秒)
HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。
G1的分区机制
-
G1收集器时,将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,为2的N次幂。
-
G1除了原有的Eden、Survivor、Old区之外,还增加了一个Humongous内存区域,主要用于存储大对象,如果占用空间超过1.5个Region,就将其放到humongous区。
-
每个Region都是通过指针碰撞来分配空间
为什么要添加Humongous内存区域
对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,那就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都在把H区作为老年代的一部分来看待。
什么是Remembered Set
记忆集,简称RSet,用来记录哪些分区引用了当前分区的对象
Remembered Set的工作机制
- 每个Region都有一个对应的Remembered Set
- 每次引用类型的数据进行写操作时,都会产生一个Write Barrier(写屏障)暂时中断操作
- 这期间会检查该引用类型的数据是否和引用指向的对象在不同的Region(其他收集器是检查老年代对象是否引用了新生代对象);如果不同,通过CardTable,把相关引用信息,记录到引用指向的对象所在的Region的Remembered Set中;如果相同,则不记录。
- 然后再进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏
以Region2为例,我们用记忆集记录了Region1跟Region3的引用指向了Region2中的对象,如果以后我们想要回收Region2,不需要遍历全堆的对象,直接查看记忆集即可
什么是dirty card queue
假设代码中有一段为对象的属性赋值的语句,例如object.fileId = object
,JVM会在这句代码的前后,保存对象的引用信息并作为card存入dirty card queue中。在年轻代回收的时候,G1会对dirty card queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系
为什么不在引用赋值语句处直接更新RSet呢?
- 这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
G1的回收过程
主要包括以下三个环节:
- 年轻代GC(Young GC)
- 老年代并发标记过程(Concurrent Marking)
- 混合回收(Mixed GC)
年轻代
-
扫描根(GC Roots)
根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
-
更新RSet
处理dirty card queue中的card,更新RSet。此阶段完成后,RSet就可以准确的反映老年代对所在的内存分段中对象的引用。
-
处理RSet
识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
-
复制对象
此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
-
处理引用
处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden区的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
(Eden区的数据为空之后,它就会变成一个空的Region,这个Region会存入LinkedList中,这个LinkedList专门存放空的Region,等下一次内存需要分配对象的时候,会直接从LinkedList中取出,接着存放数据。)
老年代并发标记过程
-
初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。
-
根区域扫描(Root Region Scanning):G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在Young GC之前完成。
-
并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
-
再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。
-
独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集
-
并发清理阶段:识别并清理完全空闲的区域。
混合回收
当越来越多的对象晋升到老年代Old Region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时进行控制。也要注意的是Mixed GC并不是Full GC。
G1回收可选的过程4 - Full GC
G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
如何查看默认的垃圾收集器
JVM查看命令行相关参数(包含使用的垃圾收集器):-XX:+PrintCommandLineFlags
使用命令行指令:jinfo -flag 相关垃圾回收器名称 进程ID
怎么选择垃圾收集器
- 优先调整堆的大小让JVM自适应完成。
- 如果内存小于100M,使用串行收集器
- 如果是单核、单机程序,并且没有停顿时间的要求,使用串行收集器
- 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
- 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
- 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。
你还知道什么垃圾收集器?
Open JDK12的shenandoash GC
-
低停顿时间的GC,旨在针对JVM上的内存回收实现低停顿的需求
-
优点是低延迟,缺点是高运行负担下的吞吐量下降
ZGC
-
ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器。
-
在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停颇时间限制在十毫秒以内的低延迟。
-
执行过程可以分为4个阶段:并发标记 - 并发预备重分配 - 并发重分配 - 并发重映射
-
它除了初始标记会STW,其它地方都是并行的。
-
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
可以开启ZGC
AliGC
-
面向大堆(LargeHeap)应用场景。指定场景下的对比:
-
-
右上图可得知在2G或8G的堆空间中,AliGC要比G1低出将近一半的时间,而平均间隔与GC相差较小
当然,还有其它厂商的一些,比如比较有名的低延迟GC Zing
GC日志分析
通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。
内存分配与垃圾回收的参数列表
-XX:+PrintGC
输出GC日志。类似:-verbose:gc
-XX:+PrintGCDetails
输出GC的详细日志-XX:+PrintGCTimeStamps
输出GC的时间戳(以基准时间的形式)-XX:+PrintGCDateStamps
输出GC的时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)-XX:+PrintHeapAtGC
在进行GC的前后打印出堆的信息-Xloggc:./logs/gc.log
日志文件的输出路径,需要在项目根目录创建logs文件夹,而不是在模块中创建
谈谈你对OOM的认识
我认为会产生OOM堆空间溢出,的原因有以下两点
- Java虚拟机的堆内存设置不够。
- 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
java.lang.OutOfMemoryError: Java heap space 堆空间溢出
对象太多,或者占用空间过大
java.lang.OutOfMemoryError: GC overhead limit exceeded GC回收时间过长
超过98%的时间都用来做GC,却只回收了不到2%的堆内存,连续多次出现这种情况,就会抛出该异常。
假如不抛出GC overhead limit 错误会发生什么情况呢?
那就是GC清理的这么点内存很快会再次填满,迫使GC再次执行。这样就形成了恶性循环,CPU使用率一直是100%,而GC却没有任何效果。
/**
* -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
*/
public class GCOverheadDemo {
public static void main(String[] args) {
int i = 0;
List<String> list = new ArrayList<>();
try {
while (true) {
list.add(String.valueOf(++i).intern());
}
} catch (Throwable e) {
System.out.println("***************i:" + i);
e.printStackTrace();
throw e;
}
}
}
java.lang.OutOfMemoryError: Direct buffer memory 直接内存溢出
ByteBuffer.allocate(capability)
以前写NIO程序的时候,会将对象分配到堆内存中,属于GC管辖,由于需要拷贝所以速度较慢
ByteBuffer.allocateDirect(capability)
现在我们将对象分配到堆外内存中,不属于GC管辖,由于不需要内存拷贝,所以速度较快,但如果不断的分配本地内存,堆内存却很少使用的话,JVM就不需要执行GC了,直接内存中的对象也不会被回收,此时的堆内存依旧充足,但本地内存可能已经用光了,再分配本地内存时就会出现OOM。
/**
* -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:MaxDirectMemorySize=5m
*/
public class DirectBufferMemoryDemo {
public static void main(String[] args) {
// 直接内存默认是物理内存的1/4
System.out.println("配置的MaxDirectMemory:" + (sun.misc.VM.maxDirectMemory() / (double) 1024 / 1024) + "MB");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
ByteBuffer bf = ByteBuffer.allocateDirect(6 * 1024 * 1024);
}
}
java.lang.OutOfMemoryError: unable to create new native thread 无法创建更多的本地线程
高并发请求服务器时,经常出现这个异常。
导致原因:
- 你的应用创建的线程太多了,一个应用进程创建多个线程,超过系统承载的极限
- 你的服务器并不允许你的应用程序创建这么多线程,Linux系统理论上,默认允许单个进程可以创建的线程数是1024个,所以你的应用创建的线程数超过这个数量时,就会报这个异常
解决办法:
- 想办法降低你应用程序创建线程的数量,分析应用是否真的需要创建这么多线程,如果不是,修改代码将线程数降到最低
- 对于某些应用,确实需要创建很多线程,远超过Linux系统的默认限制,可以通过修改Linux服务器配置,扩大Linux默认限制
非root用户登录Linux系统测试:
public class UnableCreateNewThreadDemo {
public static void main(String[] args) {
for (int i = 1; ; i++) {
System.out.println("*********** i = " + i);
new Thread(() -> {
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "" + i).start();
}
}
}
测试结果
-
-
结果,发现线程数到940,就会出现这个异常,建议线程数不超过2/3。
服务器级别的调参调优
java.lang.OutOfMemoryError: Metaspace
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* 在元空间不停的生成静态类,导致OOM
* -XX:MetaspaceSize=9m -XX:MaxMetaspaceSize=9m
*/
public class MetaspaceOOMTest {
static class OOMTest {
}
public static void main(final String[] args) {
int i = 0; // 模拟计数多少次发生异常
try {
while (true) {
i++;
// 使用cglib的动态字节码技术,不停的创建类,使用之前需要先添加cglib的jar包
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMTest.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, args);
}
});
enhancer.create();
}
} catch (Throwable e) {
System.out.println("**********" + i + "次发生了异常");
e.printStackTrace();
}
}
}
如何判断什么原因造成OOM
当我们程序出现OOM的时候,我们就需要进行排查,我们首先使用下面的例子进行说明
import java.util.ArrayList;
/**
* 使用JProfiler分析OOM
* -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
byte[] buffer = new byte[1 * 1024 *1024];//1MB
public static void main(String[] args) {
ArrayList<HeapOOM> list = new ArrayList<>();
int count = 0;
try{
while (true){
list.add(new HeapOOM());
count++;
}
} catch (Throwable e) {
System.out.println("count=" + count);
e.printStackTrace();
}
}
}
上述代码就是不断的创建一个1M小字节数组,然后让内存溢出,我们需要限制一下内存大小,同时使用HeapDumpOnOutOfMemoryError会将出错时候的dump文件输出到当前项目里
我们使用JProfiler打开生成的dump文件,然后按照下图所示就可看到问题所在
然后我们通过线程,还能看到OOM出现在什么位置
你是如何解决OOM的
- 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)
- 内存泄漏就是有大量的引用指向某些对象,但是这些对象以后不会使用了,但是因为它们还和GC ROOT有关联,所以导致这些对象不会被回收,这就是内存泄漏的问题
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GCRoots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GCRoots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
- 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xms与-Xmx),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
调优
你都用过哪些调优工具?
- JDK命令行
- Java VisualVM
- JProfiler
- GCEasy
JVM有几种参数类型
有3种参数类型
-
标配参数
在jdk各个版本都很稳定,很少有大的变化。例如:
java -version
、java -help
-
X参数(了解即可)
例如:
java -Xint
解释执行、java -Xcomp
编译执行、java -Xmixed
先编译后执行 -
*XX参数
-
Boolean类型
公式:
-XX:+/-某个属性值
,+表示开启,-表示关闭举例:
-XX+PrintGCDetails
是否打印GC细节、-XX:+UseSerialGC
是否使用串行垃圾回收器 -
KV键值对
公式:
-XX:属性key=属性值value
举例:
-XX:MetaspaceSize=128m
、-XX:MaxTenuringThreshold=15
-
解释一下-Xms和-Xmx
它们俩属于XX参数,相当于下面两个参数的简写,如果不手动设置的话,-Xms
默认使用物理内存的1/64,-Xmx
使用物理内存的1/4
-Xms
等价于-XX:InitialHeapSize
-Xmx
等价于-XX:MaxHeapSize
通常会将-Xms
和-Xmx
配置成相同的值,为了能够在Java垃圾回收机制清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能。
如何查看JVM的系统默认值
-
jinfo -flags 进程编号
查看全部配置jinfo -flag 配置项 进程编号
查看指定配置的默认值 -
java -XX:+PrintFlagsInitial
主要查看初始默认发现查出来的值,有的是=号赋值,有的是:=赋值,=意思是没有被改过,:=意思是人为修改过或者JVM修改过。
-
java -XX:+PrintFlagsFinal
主要查看修改更新的内容 -
java -XX+PrintCommandLineFlags
查看常用的初始默认值
你常用的配置参数有哪些?
-
-Xms
:设置堆区的初始内存(默认是物理内存的1/64)等价于
-XX:InitialHeapSize
-
-Xmx
:设置堆区的最大内存(默认是物理内存的1/4)等价于
-XX:MaxHeapSize
-
-Xss
:设置单个线程栈的大小一般默认为512-1024KB,系统出厂默认值是跟平台有关,一般生产环境都是部署到Linux系统(也即1024KB)
等价于
-XX:ThreadStackSize
-
-Xmn
:设置年轻代大小一般都不用设置,使用默认的即可
-
-XX:MetaspaceSize
:设置元空间大小不管是几个GB的内存,元空间默认都只占用20多MB,元空间并不在虚拟机中,而是使用本地内存
-
-XX:PrintGCDetails
:输出详细的GC处理日志 -
-XX:SurvivorRatio
:设置新生代中Eden和S0/S1空间的比例 -
-XX:NewRatio
:配置新生代与老年代在堆中的占比 -
-XX:MaxTenuringThreshold
:设置YGC中对象的年龄计数器如果设置为0的话,则新生代的对象将不经过Survivor区,直接进入老年代。
生产环境服务器变慢,诊断思路和性能评估谈谈?
整机
top命令,主要查看以下几个参数
CPU
查看CPU
vmstat工具的使用是通过两个数字参数来完成的,第一个参数是采样的时间间隔数,单位是秒,第二个参数是采样的次数
procs
- r:运行和等待CPU时间片的进程数,原则上1核的CPU的运行队列不要超过2,整个系统的运行队列不能超过总核数的2倍,否则代表系统压力过大
- b:等待资源的进程数,比如正在等待磁盘I/0、网络I/0等。
cpu
- us:用户进程消耗CPU时间的百分比,us值高,用户进程消耗CPU时间多,如果长期大于50%,优化程序
- sy:内核进程消耗的CPU时间百分比
- us + sy参考值为80%,如果us+sy大于80%,说明可能存在CPU不足。
- id:处于CPU的空闲率
- wa:系统等待IO的CPU时间百分比
- st:来自于一个虚拟机偷取的CPU时间的百分比
查看额外的CPU信息
- 每隔几秒打印一次所有CPU核的信息:
mpstat -P ALL 秒数
- 每个进程使用CPU的用量分解信息:
pidstat -u 1 -p 进程编号
内存
应用程序可用内存数
- 应用程序可用内存/系统物理内存>70%内存充足
- 应用程序可用内存/系统物理内存<20%内存不足,需要增加内存
- 20%<应用程序可用内存/系统物理内存<70%内存基本够用
查看额外
pidstat -p 进程号 -r 采样间隔秒数
硬盘
查看磁盘剩余空间df
磁盘IO
磁盘I/O性能评估
第一个参数是采样的时间间隔数,单位是秒,第二个参数是采样的次数
磁盘块设备分布
- rkB/s每秒读取数据量kB;
- wkB/s每秒写入数据量kB;
- svctm I/O请求的平均服务时间,单位毫秒;
- await I/O请求的平均等待时间,单位毫秒;值越小,性能越好;
- util 一秒中有百分之几的时间用于I/O操作。接近100%时,表示磁盘带宽跑满,需要优化程序或者增加磁盘
- rkB/s、wkB/s根据系统应用不同,会有不同的值,但有规律遵循:长期、超大数据读写,肯定不正常,需要优化程序读取。
- svctm的值与await的值很接近,表示几乎没有I/O等待,磁盘性能好,
- 如果await的值远高于svctm的值,则表示I/O队列等待太长,需要优化程序或更换更快磁盘。
查看额外
pidstat -d 采样间隔秒数 -p 进程号
网络IO
默认本地没有,需要下载
wget http://gael.roualland.free.fr/ifstat/ifstat-1.1.tar.gz
tar xzvf ifstat-1.1.tar.gz
cd ifstat-1.1I
./configure
make
make install
查看网络IO
假如生产环境出现CPU占用过高,请谈谈你的分析思路和定位
我会结合Linux和JDK命令一块分析
-
先用top命令找出CPU占比最高的
-
ps -ef|grep java|grep -v grep
或者jps -l
进一步定位,得知是哪个后台程序出的问题 -
定位到具体线程或者代码
ps -mp 进程 -o THREAD,tid,time
-m 显示所有线程
-p pid进程使用cpu的时间
-o 该参数后是用户自定义格式
-
将需要的线程ID转换为16进制格式(英文小写格式)
printf "%x\n" 有问题的线程ID
可以使用计算器,选择程序员模式,HEX就是16进制格式
-
jstack 进程ID | grep tid(16进制线程ID英文小写) -A60