JVM是Java Virtual Machine(Java虚拟机)的缩写,简单来说JVM是用来解析和运行Java程序的。
虚拟机是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java虚拟机屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
Java语言的一个非常重要的特点就是与平台的无关性,而使用Java虚拟机是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。
Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行,这就是Java的能够“一次编译,到处运行”的原因。
从Java平台的逻辑结构上来看,我们可以从下图来了解JVM:
从上图能清晰看到Java平台包含的各个逻辑模块,也能了解到JDK与JRE的区别,对于JVM自身的物理结构,请看下图:
JVM架构图:
我们都知道,Java代码的编译是由Java源码编译器来完成,流程图如下所示:
经过以上步骤生成Java字节码文件(即常说的.class文件),Java字节码文件的执行就是由JVM(java虚拟机)执行引擎来完成,流程图如下所示:
Java代码编译和执行的整个过程包含了以下三个重要的机制:
1) Java源码编译机制
Java 源码编译由以下三个过程组成:
流程图如下所示:
最后生成的class文件由以下部分组成:
结构信息:包括class文件格式版本号及各部分的数量与大小的信息。
元数据:对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池。
方法信息:对应Java源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息。
2)类加载机制
JVM的类加载是通过ClassLoader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
1. Bootstrap ClassLoader
负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类。
2. Extension ClassLoader
负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包。
3. App ClassLoader
负责记载classpath中指定的jar包及目录中class。
4. Custom ClassLoader
属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。
3)类执行机制
JVM是基于栈的体系结构来执行class字节码的。线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。栈的结构如下图所示:
1)JVM 内存组成结构
JVM栈由堆、栈、本地方法栈、方法区等部分组成,结构图如下所示:
1. 堆
所有通过new创建的对象的内存都在堆中分配,堆的大小可以通过-Xmx和-Xms来控制。堆被划分为新生代和旧生代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Space和To Space组成,结构图如下所示:
新生代。新建的对象都是用新生代分配内存,Eden空间不足的时候,会把存活的对象转移到Survivor中,新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制Eden和Survivor的比例。
旧生代。用于存放新生代中经过多次垃圾回收仍然存活的对象。
持久带(Permanent Space)实现方法区,主要存放所有已加载的类信息,方法信息,常量池等等。可通过-XX:PermSize和-XX:MaxPermSize来指定持久带初始化值和最大值。Permanent Space并不等同于方法区,只不过是Hotspot JVM用Permanent Space来实现方法区而已,有些虚拟机没有Permanent Space而用其他机制来实现方法区。
2. 栈
每个线程执行每个方法的时候都会在栈中申请一个栈帧,每个栈帧包括局部变量区和操作数栈,用于存放此次方法调用过程中的临时变量、参数和中间结果。
-xss:设置每个线程的堆栈大小. JDK1.5+ 每个线程堆栈大小为 1M,一般来说如果栈不是很深的话, 1M 是绝对够用了的。
3. 本地方法栈
用于支持native方法的执行,存储了每个native方法调用的状态。
4. 方法区
存放了要加载的类信息、静态变量、final类型的常量、属性和方法信息。JVM用持久代(Permanet Generation)来存放方法区,可通过-XX:PermSize和-XX:MaxPermSize来指定最小值和最大值。
2)JVM内存模型讲解图
堆和方法区都是线程共享的区域,主要用来存放对象的相关信息。我们知道,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,因此, 这部分的内存和回收都是动态的,垃圾收集器所关注的就是这部分内存。
在JDK1.7和1.8对这部分内存的分配也有所不同,下面我们来详细看一下:
正常关闭:当最后一个非守护线程结束或调用了System.exit或通过其他特定于平台的方式,比如ctrl+c。
强制关闭:调用Runtime.halt方法,或在操作系统中直接kill(发送single信号)掉JVM进程。
异常关闭:运行中遇到RuntimeException 异常等。
在某些情况下,我们需要在JVM关闭时做一些扫尾的工作,比如删除临时文件、停止日志服务。为此JVM提供了关闭钩子(shutdown hocks)来做这些事件。
Runtime类封装java应用运行时的环境,每个java应用程序都有一个Runtime类实例,使用程序能与其运行环境相连。
关闭钩子本质上是一个线程(也称为hock线程),可以通过Runtime的addshutdownhock (Thread hock)向主jvm注册一个关闭钩子。hock线程在jvm正常关闭时执行,强制关闭不执行。
对于在jvm中注册的多个关闭钩子,他们会并发执行,jvm并不能保证他们的执行顺序。
Java程序在运行时,会为JVM单独划出一块内存区域,而这块内存区域又可以再次划分出一块运行时数据区,运行时数据区域大致可以分为五个部分:
JVM逻辑内存模型:
JDK1.8 内存模型:
JVM内存会划分为堆内存和非堆内存,堆内存中也会划分为年轻代和老年代,而非堆内存则为永久代。
新生代Young和老年代Old默认占比是1:3。
年轻代又会分为Eden和Survivor区,Survivor也会分为FromPlace和ToPlace,Eden、FromPlace和ToPlace的默认占比为 8:1:1。
内存模型要点:
一句话概括便是:栈管运行,堆管存储,虚拟机栈负责运行代码,而虚拟机堆负责存储数据。
存放所有的类对象实例,一个JVM实例只存在一个堆内存,所有的对象的内存都在这里分配。
除了实例数据,还保存了对象的其他信息,如Mark Word(存储对象哈希码,GC标志,GC年龄,同步锁等信息),Klass Pointy(指向存储类型元数据的指针)及一些字节对齐补白的填充数据(若实例数据刚好满足8字节对齐,则可不存在补白)。
Java堆区具有下面几个特点:
Java虚拟机所需要管理的内存中最大的一块。
存储的是我们new来的对象,不存放基本类型和对象引用。
由于创建了大量的对象,垃圾回收器主要工作在这块区域。
堆内存物理上不一定要连续,只需要逻辑上连续即可,就像磁盘空间一样。
线程共享区域,因此是线程不安全的,整个Java虚拟机只有一个堆,所有的线程都访问同一个堆。它是被所有线程共享的一块内存区域,在虚拟机启动时创建,而程序计数器、Java虚拟机栈、本地方法栈都是一个线程对应一个。
能够发生OutOfMemoryError。
堆是垃圾回收的主要区域,所以也被称为GC堆。
堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的(通过-Xmx和-Xms控制),因此当线程请求分配内存。但堆已满,且内存已满无法再扩展时,就抛出OutOfMemoryError。
Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,是JVM管理的最大一块内存空间。
堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在这里还可以划分线程私有的缓存区(Thread Local Allocation Buffer,TLAB)。
1)TLAB线程私有缓存区
为什么有TLAB(Thread Local Allocation Buffer)?
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。
由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是TLAB?
从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有的缓存区域,它包含在Eden空间内。
多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
所有OpenJDK衍生出来的JVM都提供了TLAB的设计。
尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
在程序中,开发人员可以通过选项 "-XX:UseTLAB" 设置是否开启TLAB空间。默认是开启的。
默认情况下,TLAB空间的内存非常小,仅占整个Eden空间的1%,当然我们可以通过选项"-XX:TLABWasteTargetPercent"设置TLAB空间所占用Eden空间的百分比大小。
一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。
对象分配过程:
2)年轻代与老年代
存储在JVM中的Java对象可以被划分为两类:一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。另一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)
其中年轻代又可以划分为Eden空间、Survivor0 空间和 Survivor1 空间(有时也叫做from区、to区)
其实,Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区。具体比例参数的话,可以看一下这张图:
老年代:2/3的堆空间;
年轻代:1/3的堆空间;
eden区:8/10 的年轻代;
survivor0: 1/10 的年轻代;
survivor1:1/10的年轻代;
新生代=1个Eden区+ 2个Survivor区,几乎所有的Java对象都是在Eden区被new出来的,当Eden区装填满的时候,会触发Young GC。
可以使用选项"-Xmn"设置新生代最大内存大小(一般不会使用这个配置)。
垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收,依然存活的对象会被移送到Survivor区,这个区真是名副其实的存在Survivor 区分为S0和S1两块内存空间,送到哪块空间呢?
每次Young GC的时候,将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。如果YGC要移送的对象大于Survivor区容量上限,则直接移交给老年代。
假如一些没有进取心的对象以为可以一直在新生代的Survivor区交换来交换去,那就错了。每个对象都有一个计数器,每次YGC都会加1。
-XX:MaxTenuringThreshold
参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升至老年代。如果该参数配置为1,那么从新生代的Eden区直接移至老年代。
默认值是15,可以在Survivor 区交换14次之后,晋升至老年代。
配置新生代与老年代在堆结构的占比:
默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。
可以修改 -XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5。
在HotSpot中,Eden空间和另外两个Survivor空间缺省所占的比例是 8:1:1。当然开发人员可以通过选项"-XX:SurvivorRatio"调整这个空间比例。比如:-XX:SurvivorRatio=8。
再细致一点的有Eden 空间、From Survivor 空间、To Survivor 空间等,如果从内存分配的角度看,线程共享的Java 堆中可能划分出多个线程私有的分配缓冲区(TLAB)。
不过,无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
3)对象分配过程
对象分配过程概述:
1. new的对象放在伊甸园区,此区大小有限制。
2. 当伊甸园区的空间填满时,程序有需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放在伊甸园区。
3. 然后将伊甸园区中的剩余对象移动到幸存者0区。
4. 如果再次触发垃圾回收,此时上次幸存下来放在幸存者0区的对象,如果没有被回收,就会放到幸存者1区。
5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
6. 啥时候去养老区?可以设置次数(对象年龄)。默认是15次。
可以设置参数:-XX:MaxTenuringThreshold=进行设置
7. 当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。
8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常。
java.lang.OutOfMemoryError:Java heap space
内存分配策略:
如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold 来设置。
针对不同年龄段的对象分配原则,如下所示:
1. 优先分配到Eden。
2. 大对象直接分配到老年代,尽量避免程序中出现过多的大对象。
3. 长期存活的对象分配到老年代。
4. 动态对象年龄判断。
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
5. 空间分配担保
-XX:HandlePromtionFailure
总结:
新生代:有Eden、两块大小相同的Survivor(又称from/to,s0/s1)构成,to总为空。
老年代:存放新生代中经历多次GC仍然存活的对象。
针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
4)MinorGC、MajorGC、FullGC
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堆和方法区的垃圾收集。
1. 年轻代GC(Minor GC)触发机制
当年轻代空间不足时,就会触发Minor GC,这里的年轻代指的是Eden区满,Survivor区满不会引发GC(每次Minor GC会清理年轻代的内存)。
因为Java对象大多是具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
Minor GC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。
2. 老年代GC(Major GC / Full GC)触发机制
指发生在老年代的GC,对象从老年代消失时,Major GC或Full GC发生。
出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC。
Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
如果Major GC后,内存还不足,就报OOM。
3. Full GC触发机制
触发Full GC执行的情况有如下五种:
1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
2. 老年空间不足
3. 方法区空间不足
4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
5. 有Eden区、Survivor space0(From Space)区向Survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
5)堆空间参数设置
Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用。若Survivor区无法放下,或者超大对象的阈值超过上限,则尝试在老年代中进行分配。如果老年代也无法放下,则会触发Full Garbage Collection(Full GC),如果依然无法放下,则抛OOM。
堆空间参数,请参考官网说明:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-XX:+PrintFlagsInitial 查看所有的参数的默认初始值
-XX:+PrintFlagsFinal 查看所有的参数的最终值(可能会存在修改,不再是初始值)
-Xms: 初始堆空间内存(默认为物理内存的1/64)
-Xmx: 最大堆空间内存(默认为物理内存的1/4)
-Xmn: 设置新生代的大小(初始值及最大值)
-XX:NewRatio 配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio 设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold 设置新生代垃圾的最大年龄
-XX:+PrintGCDetails 输出详细的GC处理日志
①-XX:+PrintGC ②-Verbose:gc 打印GC简要信息
-XX:HandlePromotionFailure 是否设置空间分配担保
1. 在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
如果大于,则此次Minor GC是安全的;
如果小于,则虚拟机会查看-XX:HandlePromotioinFailure设置值是否允许担保失败;
2. 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
如果小于,则改为进行一次Full GC;
3. 如果HandlePromotionFailure=false,则改为进行一次Full GC。
在JDK7之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK7之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。
堆出现OOM的概率是所有内存耗尽异常中最高的,出错时的堆内信息对解决问题非常有帮助,所以给JVM设置运行参数:
XX:+HeapDumpOnOutOfMemoryError
让JVM遇到OOM异常时能输出堆内信息。
通常情况下,堆占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间堆的内存空间既可以固定大小,也可运行时动态地调整,通过如下参数设定初始值和最大值,比如:
-Xms256M. -Xmx1024M
其中-X表示它是JVM运行参数:
ms是memorystart的简称,堆区的起始内存,等价于"-XX:InitialHeapSize"。
mx是memory max的简称,堆区的最大内存,等价于"-XX:MaxHeapSize"。
一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。
但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力。所以在线上生产环境中,JVM的Xms和Xmx设置成一样大小,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,避免在GC后调整堆大小时带来的额外压力,从而提高性能。
配置建议:
默认情况下,初始内存大小:物理内存大小1/64,最大内存大小:物理内存1/4,Full GC时开发或调优中尽量要避免的,这样暂停时间会短一些。
-XX:NewRatio:设置新生代与老年代的比例。默认值是2
-XX:SurvivorRatio:设置新生代中Eden区与Survivor区的比例
-XX:-UseAdaptiveSizePolicy:关闭自适应的内存分配粗略
(在实际操作中,设置并未生效,使用-XX:SurvivorRatio=8指定Eden区比例)
6)逃逸分析
堆是分配对象的唯一选择吗?
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也逐渐变得不那么"绝对"了。
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无须在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,依次达到降低GC的回收频率和提升GC的回收效率的目的。
逃逸分析概述
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
1. 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
2. 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如:作为调用参数传递到其他方法中。
// 没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。
public void myMethod() {
Person person = new Person();
//...
person = null;
}
如何快速的判断是否发生了逃逸分析,就看new的对象是否有可能在方法外被调用。
参数设置:
在JDK7之后,HotSpot中默认就已经开启了逃逸分析。
如果使用的是较早的版本,开发人员则可以通过:
选项"-XX:+DoEscapeAnalysis"显示开启逃逸分析;
通过选项"-XX:+PrintEscapeAnalysis"查看逃逸分析的筛选结果;
开发中能使用局部变量,就不要在方法外定义。
总结:
jvm内存分为堆内存和非堆内存,堆内存分为年轻代、老年代,非堆内存里只有个永久代。
年轻代分为生成区(Eden)和幸存区(Survivor),幸存区由FromSpace和Tospace两部分组成,默认情况下,内存大小比例:Eden:FromSpace:ToSpace 为 8:1:1。
堆内存存放的是对象,垃圾收集器回收的就是这里的对象,不同区域的对象根据不同的GC算法回收,比如年轻代对应Minor GC,老年代对应Major GC。
非堆内存即永久代,也称为方法区,存储的是程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。
在jdk1.8废弃了永久代,使用元空间(MetaSpace)取而代之,元空间存储的对象与永久代相同,区别是:元空间并不在jvm中,使用的是本地内存。
为除永久代是为了融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。
java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的。
虚拟机栈运行原理:
每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中各种数据信息。
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循"先进后出"/"后进先出"原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果个前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
虚拟机栈作用:
主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
虚拟机栈优点:
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。对于栈来说不存在垃圾回收问题。
JVM直接对Java栈的操作只有两个:
1. 每个方法执行,伴随着进栈(入栈、压栈)。
2. 执行结束后的出栈(弹栈)。
虚拟机栈生命周期与线程相同,启动一个线程,程序调用函数,栈帧被压入栈中,函数调用结束,相应的是栈帧的出栈。每个方法从调用到执行完毕,对应一个栈帧在虚拟机栈中的入栈和出栈。通常所说的栈,一般是指在虚拟机栈中的局部变量部分。
局部变量所需内存在编译期间完成分配,如果线程请求的栈深度大于虚拟机所允许的深度,则StackOverflowError。如果虚拟机栈可以动态扩展,扩展到无法申请足够的内存,则OutOfMemoryError。
设置栈内存大小:
使用参数 -Xss 选项设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
栈帧内部结构:
每个栈帧中存储:
1. 局部变量表(Local Variables);
2. 操作数栈(Operand Stack);
3. 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用);
4. 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义);
等一些附加信息。
1)栈帧 :由局部变量表,操作数栈,帧数据区组成。
相对于类属性变量的准备阶段和初始化阶段来说,局部变量没有准备阶段,必须显式初始化
如果是非静态方法,则在index[0]位置上存储的是方法所属对象的实例引用,随后存储的是参数和局部变量。字节码指令中的STORE指令就是将操作栈中计算完成的局部变量写回局部变量表的存储空间内。
2)局部变量表:存放的是函数的入参,以及方法体内的局部变量。
定义为一个数字数组,存放编译期可知的各种基本数据类型、对象引用类型和returnAddress类型(指向一条字节码指令的地址:函数返回地址)。由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
long、double占用两个局部变量控件Slot。局部变量表所需的内存空间在编译期确定,当进入一个方法时,方法在栈帧中所需要分配的局部变量控件是完全确定的,不可动态改变大小。
Slot 变量槽
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
局部变量表最基本的存储单元是Slot(变量槽)
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
在局部变量表里,32位以内的类型只占用一个Slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
long和double则占据两个slot。
JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。
如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index0的Slot处,其余的参数按照参数表顺序继续排列。
Slot的重复利用:
栈帧中的局部变量表中的槽位是可以复用的,如果一个局部变量超过其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量槽位,从而达到节省资源的目的。
public class SlotTest {
public void localVarl() {
int a = 0;
System.out.println(a);
int b = 0;
}
public void localVar2() {
// 变量a的作用域在{}内,执行过后,变量a失效
{
int a = 0;
System.out.println(a);
}
// 此时的b就会复用a的槽位
int b = 0;
double c = 0;
char d = 'a';
}
}
SlotTest.class字节码localVar2()栈帧的局部变量表:
静态变量与局部变量的对比:
参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。
我们知道类变量表有两次初始化的机会,第一次是在"准备阶段",执行系统初始化,对类变量设置零值,另一次则是在"初始化"阶段,赋予我们在代码中定义的初始值。
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须赋予初始值,否则无法使用。
局部变量表中的变量只在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
public class Test {
public void test() {
int i;
// 错误使用局部变量i,局部变量没有赋值不能使用
System.out.println(i);
}
}
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
3)操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop),后进先出LIFO,最大深度由编译期确定。
栈帧刚建立使,操作数栈为空,执行方法操作时,操作数栈用于存放JVM从局部变量表复制的常量或者变量,提供提取,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。
将某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用它们后再把结果压入栈。比如:执行复制、交换、求和等操作。
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的 操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。
操作数栈可以存放一个jvm中定义的任意数据类型的值。在任意时刻,操作数栈都一个固定的栈深度,基本类型除了long、double占用两个深度,其它占用一个深度。
① 32bit的类型占用一个栈单位深度;
② 64bit的类型占用两个栈单位深度;
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须于字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
栈顶缓存技术:
前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM设计者们提出了栈顶缓存(ToS,Top-of-Stack)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
4)帧数据区:存放的是异常处理表和函数的返回,访问常量池的指针。
举个例子,线程执行进入方法A,则会创建栈帧入栈,A方法调用了B方法,B栈帧入栈,B方法中调用C方法,C创建了栈帧压入栈中,接下来是D入栈
反过来,D方法执行完,栈帧出栈,接着是C,B,A。
在方法执行过程中,会有各种指令往栈中写入和提取信息,JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是操作栈。
字节码指令集的定义都是基于栈类型的,栈的深度在方法元信息的stack属性中。
下面用一段简单的代码说明操作栈与局部变量表的交互:
详细的字节码操作顺序如下:
第1处说明:局部变量表就像个中药柜,里面有很多抽屉,依次编号为0, 1, 2,3...n。字节码指令istore_ 1就是打开1号抽屉,把栈顶中的数13存进去。
栈是一个很深的竖桶,任何时候只能对桶口元素进行操作,所以数据只能在栈顶进行存取。
某些指令可以直接在抽屉里进行,比如inc指令,直接对抽屉里的数值进行+1操作,程序员面试过程中,常见的i++和++i的区别,可以从字节码上对比出来。
iload_ 1从局部变量表的第1号抽屉里取出一个数,压入栈顶,下一步直接在抽屉里实现+1的操作,而这个操作对栈顶元素的值没有影响,所以istore_ 2只是把栈顶元素赋值给a。
表格右列,先在第1号抽屉里执行+1操作,然后通过iload_ 1 把第1号抽屉里的数压入栈顶,所以istore_ 2存入的是+1之后的值。
这里延伸一个信息,i++并非原子操作。即使通过volatile关键字进行修饰,多个线程同时写的话,也会产生数据互相覆盖的问题。
5)动态连接:每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。
Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。常量池的作用就是为了提供一些符号和常量,便于指令的识别。
6)方法的调用:
静态链接与动态链接
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接:
如果被调用的方法在编译期无法确定下来,也就是说只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
对应的方法绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定与晚期绑定
早期绑定:
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
虚方法与非虚方法
Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于c++语言中的虚函数。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
非虚方法:
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
其他方法称为虚方法。
指令调用
虚拟机中提供了以下几条方法调用指令:
普通调用指令:
1.invokestatic:调用静态方法,解析阶段确定唯一方法版本
2.invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
3.invokevirtual:调用所有虚方法
4.invokeinterface:调用接口方法
动态调用指令:
5.invokedynamic:动态解析出需要调用的方法,然后执行。
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持有用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
动态类型语言与静态类型语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之就是动添类型语言。简单来说,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态类型语言的一个重要特征。
静态类型语言Java:
String info = "str";
动态类型语言JS、Python:
var name = "fawaikuangtu";
info = "zhangsan"
方法重写的本质
Java语言中方法重写的本质:
1.找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
2.如果在类型C中找到常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.illegalAccessError异常。
3.否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
illegalAccessError异常介绍:
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法什么时候被创建?
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成后,JVM会把该类的方法表也初始化完毕。
虚方法表示例:
说明:
Father类中hardChoice两个重载方法直接指向Father类,Son类继承了Father类重写了hardChoice两个重载方法,指向了Son类,Object类中的方法没有被两个子类重写,所以直接指向了Object类。
7)方法返回地址:存放调用该方法的pc寄存器的值。
当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令正常退出或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。
方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:需要恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令,让调用者方法继续执行下去。
正常完成退出和异常完成退出的区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。
当一个方法开始执行后,只有两种方式可以退出这个方法:
1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法,实例初始化方法、类和接口的初始化方法使用
2. 在方法执行过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
方法执行过程中抛出的异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
一些附加信息:
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
8)异常:线程请求的栈帧深度大于虚拟机所允许的深度—StackOverFlowError,如果虚拟机栈可以动态扩展(大部分虚拟机允许动态扩展,也可以设置固定大小的虚拟机栈),但是无法申请到足够的内存—OutOfMemorError。
虚拟机栈特点总结:
Java虚拟机栈是线程私有的,每一个线程都有独享一个虚拟机栈,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
存放基本数据类型(boolean、byte、char、short、int、float、long、double)以及对象的引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法(Native方法)而服务。
本地方法栈,也是线程私有的,允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面是相同的)。
虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常。
如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。
什么是Native Method?简单地讲,一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java的方法:该方法的实现由非java语言实现,比如C。这个特征并非java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告知C++编译器去调用一个C的函数。
具体在Native Method Stack中登记本地方法(native方法),在Execution Engine 执行时加载本地方法库。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。
例如:
public class IHaveNatives {
public native void methodNative1(int x);
public native static long methodNative2();
public native synchronized float methodNative3(Object o);
native void methodNative4(int[] arr) throws Exception;
}
标识符native可以与所有其他的java标识符连用,但是abstract除外
为什仫要使用Native Method?
java使用起来非常方便,然而有些层次的任务用java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。
与java环境外交互:有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。
与操作系统交互:JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层(underneath在下面的)系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
DLL的加载是通过调用System.loadLibrary(String filename)或System.load(String filename)方法实现的。
本地方法特点:
1. 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
2. 它甚至可以直接使用本地处理器的寄存器
3. 直接从本地内存的堆中分配任意数量的内存
4. 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
现状:
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过java程序驱动打印机或者java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等。
对方法区(Method Area)存储内容描述如下:它用于存储已被虚拟机加载的类型信息、常量、静态变量、即使编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:
①这个类型的完整有效名称(全名=包名.类名)
②这个类型直接父类的完整有效名(对于interface或是java.lang.Object,都没有父类)
③这个类型的修饰符(public、abstract、final的某个子集)
④这个类型直接接口的一个有序列表
域(Field)信息
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的类常量
静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
类变量被类的所有实例共享,即使没有类实例时也可以访问它。
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 void hello() {
System.out.println("hello!");
}
}
全局常量
被声明为final的类变量的处理方法则不同,每个全局常量(final static 修饰的)在编译的时候就会被分配了。
常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
方法区,内部包含了运行时常量池。
字节码文件,内部包含了常量池。
要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。
要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域、方法的符号引用。
为什么需要常量池?
一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。如以下代码:
public class SimpleClass {
public void sayHello() {
System.out.println("hello");
}
}
编译后的字节码文件只有194字节,但是里面却引用了String、System、PrintStream及Object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多,这里就需要常量池了。
常量池中有什么?
几种在常量池内存储的数据类型包括:
数量值
字符串值
类引用
字段引用
方法引用
如:
public class MethodAreaTest {
public static void main(String[] args) {
Object obj = new Object();
}
}
将会被编译成如下字节码:
0: new #2 // Class java/lang/Object
1: dup
2: invokespecial #3 // Method java/lang/Object ""() V
总之常量池,可以看做是一张表,虚拟机指令根据这张常量表找到执行的类名、方法名、参数类型、字面量等类型。
栈、堆、方法区的交互关系:
和java堆一样,方法区是一块所有线程共享的内存区域。方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
方法区保存系统的类信息,比如,类的字段,方法,常量池等。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,方法区无法满足新的内存分配需求时,将抛出OOM内存溢出错误:java.lang.OutOfMemoryError:Metaspace(Java8)或 java.lang.OutOfMemoryError:PermGen space(Java7)。
比如,加载大量的第三方jar包;Tomcat部署的工程过多、大量动态的生成反射类等,关闭JVM就会释放这个区域的内存。
方法区主要特点如下:
线程共享区域,因此这是线程不安全的区域。
它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
jdk1.6和jdk1.7方法区可以理解为永久区。
jdk1.8已经将方法区取消,替代的是元数据区。
jdk1.8的元数据区可以使用参数-XX:MaxMetaspaceSzie设定大小,这是一块堆外的直接内存,与永久区不同,如果不指定大小,默认情况下,虚拟机会耗尽可用系统内存。
HotSpot方法区的演进:
JDK6:
有永久代(permanent generation),静态变量存放在永久代上。
JDK7:
有永久代,但已经逐步"去永久代",字符串常量池、静态变量移除,保存在堆中。
JDK8:
无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中。
永久代为什么要被元空间替换?
随着Java8的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,那么他们之间的区别到底是什么?方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。不过Java 8以后就没有永久代这个说法了,元空间取代了永久代。
这项改动是很有必要的,原因有:
1. 为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
2. 对永久代进行调优是很困难的。
String Table为什么要调整?
jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在Full GC的时候才会触发。而Full GC是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
方法区的垃圾回收策略
Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。
这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。
字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
1、类和接口的全限定名
2、字段的名称和描述符
3、方法的名称和描述符
HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
回收废弃常量与回收Java堆中的对象非常类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息
在大量使用反射、动态代理、GCLib等字节码框架,动态生成JSP及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
如何解决OOM?
1. 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Momory Leak)还是内存溢出(Memory Overflow)。
2. 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径于GC Roots相关联导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
3. 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活者,那就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
设置方法区大小与OOM
方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。
JDK7及以前:
通过-XX:PermSize来设置永久代初始分配空间,默认值是20.75M。
-XX:MaxPermSize来设定永久代最大可分配空间,32位机器默认是64M,64位机器默认是82M。
当JVM加载的类信息容量超过了这个值,会报异常:OutOfMemoryError:PermGen Space。
JDK8及以后:
元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 指定,替代上述原有的两个参数。
默认值依赖于平台。windows下:-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是 -1,即没有限制。
于永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常:OutOfMemoryError:Metaspace。
-XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器JVM来说,其默认的 -XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。
如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁GC,建议将 -XX:MetaspaceSize设置为一个相对较高的值。
元数据区域取代了1.7版本及以前的永久代。元数据和永久代本质上都时方法区的实现。方法区皴法虚拟机加载的类型西,静态变量,常量数据。
参数设置:
-XX:MetaspaceSize=18m
-XX:MaxMetaspaceSize=60m
程序计数器是一个记录着当前线程所执行的字节码的行号指示器,由执行引擎读取下一条指令。它是一块很小的内存空间,几乎可以忽略不计,也是运行速度最快的存储区域。
每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
进程是资源分配的最小单位,线程是CPU调度的最小单位,一个进程可以包含多个线程, Java线程通过抢占的方法获得CPU的执行权。
现在可以思考下面这个场景:
某一次,线程A获得CPU的执行权,开始执行内部程序。但是线程A的程序还没有执行完,在某一时刻CPU的执行权被另一个线程B抢走了。后来经过线程A的不懈努力,又抢回了CPU的执行权,那么线程A的程序又要从头开始执行?
这个时候程序计数器就粉墨登场了,它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。
JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。
程序计数器还具有以下特点:
线程隔离性,每个线程工作时都有属于自己的独立计数器,因此它是线程安全的。
执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址。
执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。
程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。
唯一一块不存在OutOfMemoryError的区域,可能是设计者觉得没必要。
执行指令:
javap -v ***.class 例:javap -v PCRegisterTest.class
使用程序计数器存储字节码指令地址作用:
因为CPU需要不停的切换各个线程,每当切换到当前线程就得知道接着从哪开始继续执行。
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
为什么程序计数器被设定为线程私有的:
我们知道所谓的多线程在一个特定的时间段只会执行其中某一个线程的方法,CPU会不停的做任务切换,这样必然导致经常中断或恢复,为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每个线程都分配一个程序计数器,这样一来各个线程之间便可以独立计算,从而不会出现相互干扰的问题。
由于CPU时间片限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中一个内核,只会执行某个线程中的指令。这样必然导致经常中断或恢复,每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程中相互不影响。(CPU时间片即CPU分配给各个程序的执行时间,每个线程被分配一个时间段,称作它的时间片。
宏观上:我们可以打开多个应用程序,每个程序并行不悖,同时运行。微观上:由于只有一个CPU,一次只能处理程序的一部分,要保证处理公平,一种方法就是引入时间片,每个程序轮流执行。)
执行引擎是Java虚拟机核心的组成部分之一。
"虚拟机"是一个相对于"物理机"的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
JVM的主要任务是负责装载字节码到其内部,但字节码并不能直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎的工作过程:
1. 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
2. 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
3. 当方法在执行过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。
从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
Java代码编译和执行过程:
大部分的程序代码转换成物理机的目标或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。
Java代码编译是由Java源码编译器来完成,流程图如下所示:
Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:
什么是解释器(Interpreter),什么是JIT编译器?
解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容"翻译"为对应平台的本地机器指令执行。
JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。
为什么说Java是半编译半解释型语言?
JDK1.0时代,将Java语言定位为"解释执行"还是比较准确的,再后来,Java也发展出可以直接生成本地代码的编译器。现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
解释器工作机制:
解释器真正意义上所承担的角色就是一个运行时"翻译者",将字节码文件种的内容"翻译"为对应平台的本地机器指令执行。
当一条字节码指令被解释执行完成后,接着再根据PC寄存器种记录的下一条需要被执行的字节码指令执行解释操作。
解释器分类:
在Java的发展历史中,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。而模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
在HotSpot VM中,解释器主要由Interpreter模块和Code模块构成。
Interpreter模块:实现了解释器的核心功能
Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,直接内存(Direct Memory)就是Java堆外内存、直接向系统申请的内存空间。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
通过存在堆中的DirectByteBuffer操作Native内存,访问直接内存的速度会优于Java堆,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存,Java的NIO库允许Java程序使用直接内存,用于数据缓冲区。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。
服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。当各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
缺点:
1. 分配回收成本较高
2. 不受JVM内存回收管理
直接内存大小可以通过MaxDirectMemorySize设置,如果不指定,默认与堆的最大值-Xmx参数值一致。
运行时常量池(Runtime Constant Pool),是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到常量池中。
运行时常量是相对于常量来说的,它具备一个重要特征是:动态性。当然,值相同的动态常量与我们通常说的常量只是来源不同,但是都是储存在池内同一块内存区域。Java语言并不要求常量一定只能在编译期产生,运行期间也可能产生新的常量,这些常量被放在运行时常量池中。
这里所说的常量包括:基本类型包装类(包装类不管理浮点型,整形只会管理-128到127)和String(也可以通过String.intern()方法可以强制将String放入常量池)。
1)对象的实例化
创建对象步骤:
①加载类元信息 -> ②为对象分配内存 -> ③处理并发问题 -> ④属性的默认初始化(零值初始化)-> ⑤设置对象头的信息 -> ⑥属性的显式初始化、代码块中初始化、构造器中初始化
1. 判读对象的类是否加载、链接、初始化:
虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。
2. 为对象分配内存:
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量引用变量,仅分配引用变量空间即可,即4个字节大小。
指针碰撞:
如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Pointer)来为对象分配内存。意是是所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有compact(整理)过程的收集器时,使用指针碰撞。
空闲列表分配:
如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式称为"空闲列表(Free List)"。
说明:
选择哪种分配方式有Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
3. 并发处理
并发面临的三个问题:
① 原子性:不可分割。
② 可见性:线程只能操作自己工作空间里的数据。
③ 有序性:程序中的顺序不一定是CPU的执行顺序,通过编译重排序和指令重排序来提高效率。
并发一致性问题:
① 总线加锁,缺点:降低CPU的吞吐量
② 缓存上的一致性协议(MESI):当CPU在cache中操作数据时,如果该数据时共享变量,那么数据在cache读到寄存器中,然后进行修改,同时将数据的标识符(cache line)置为无效,这样其他的CPU就会从内存中去读取数据,而不是读自己的缓存中的数据了。
是利用锁的机制来实现同步的,锁机制有如下两种特性:
互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性。
可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致。
Volatile和Synchronized的区别:
synchronized引起阻塞,Volatile不会引起阻塞,Volatile让其他线程能够马上感知到某一线程对某个变量的修改。
1)保证可见性,对共享变量的修改,其他线程马上能感知到,但不能保证原子性。
2)保证有序性,在JVM的编译阶段和指令优化排序阶段,对于volatile修饰的变量,其代码顺序不会改变。
synchronized可以保证原子性,Volatile不能保证原子性。Volatile对变量加了lock,synchronized使用monitorEnter和monitorexit monitor JVM保证了可见性。
Volatile能保证有序,synchronized可以保证有序性,但是代价(重量级)并发退化到串行。
锁原理:
在每个Java对象中都会有一个Monitor对象(也就是这个对象的监听器,原理是一个计数器),当某一个线程在占用这个对象的时候,首先判断这个monitor对象的计数器是不是0,如果是0,说明这个对象还没有被其他线程占用,这时候这个线程就可以占用这个对象了,并且把monitor+1。
如果这个monitor不是0,则说明这个对象已经被别的线程给占用了,这个时候,此线程需要等待。当线程释放占用权的时候,monitor-1,。这时候通过 javap 命令查询反编译文件时,是有一个monitorenter和一个monitorexist与之对应的(但是在Synchronized修饰的方法时,是在方法标识中声明的,并没有monitorenter和monitorexist)。
JDK对Synchronized的优化:在jdk1.6之前,jvm只有一种重量级锁,在jdk1.6之后,jvm对Synchronized进行了优化,增加了偏向锁和轻量级锁。
几种锁对比:
无状态锁:没有加锁
偏向锁:偏向锁更加偏偏向锁是指在对象头信息中会记录一个线程id,如果是相同的线程来访问同步代码块,那么就相当于是无锁状态。在对象第一次被某一线程占有的时候,会将“是否偏向锁”字段置为1,“锁标志位”记为01,写入线程号,当其他的线 程访问的时候,就会发生竞争,如果竞争失败则升级为轻量级锁,向第一次访问的线程获取锁成功。
轻量级锁:线程有交替适用,互斥性不是很强,当偏向锁通过CAS算法获取锁失败,把锁标志位置为00。
重量级锁:强互斥,锁标志位为10,等待时间长
5. 设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。
6. 执行init方法进行初始化
在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
因此一般来说(由字节码中是否跟随着有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照开发者的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
2)对象的内存布局
对于Java堆、方法区、线程独享区域(主要是虚拟机栈),方法的执行都是伴随着线程的,原始类型的本地变量以及引用都存放在线程栈中,而引用关联的对象比如String,都存在在堆中。
为了更好的理解上面这幅图,我们可以看一个例子:
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.logging.Logger;
public class HelloWorld {
private static Logger LOGGER = Logger.getLogger(HelloWorld.class.getName());
public void sayHello(String message) {
SimpleDateFormat formatter = new SimpleDateFormat("dd.MM.YYYY");
String today = formatter.format(new Date());
LOGGER.info(today + ":" + message);
}
}
我们先回顾一下前面学习的知识:
堆存储的是我们new来的对象,不存放基本类型和对象引用;
栈存储存放基本数据类型、对象的引用和returnAddress类型;
方法区存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
这段程序的数据在内存中的存放如下:
以看出数据进行如下存放:
Java堆:对象HelloWorld、对象SimpleDateFormat、对象String和对象LOGGER;
线程独享区域(主要是虚拟机栈):message的引用、formatter的引用、today的引用;
方法区:类信息SimpleDateFormat、类信息Logger、类信息HelloWorld、方法sayHello(),还包括类信息的所有方法。
对象内部布局案例:
public class Customer{
int id = 1001;
String name;
Account acct;
{
name = "匿名客户";
}
public Customer(){
acct = new Account();
}
}
class Account{
}
3)对象的访问定位
java程序需要通过引用(ref)数据来操作堆上面的对象,那么如何通过引用定位、访问到对象的具体位置。
对象的访问方式由虚拟机决定,java虚拟机提供两种主流的方式:
句柄访问对象
直接指针访问对象。(Sun HotSpot使用这种方式)
1. 句柄访问
简单来说就是java堆划出一块内存作为句柄池,引用中存储对象的句柄地址,句柄中包含对象实例数据、类型数据的地址信息。
优点:引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身。
2. 直接指针
与句柄访问不同的是,ref中直接存储的就是对象的实例数据,但是类型数据跟句柄访问方式一样。
优点:优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,平时我们常用的JVM HotSpot采用此种方式】。
Java内存模型对比表:
Java内存模型对比图:
如果 JVM 想要执行这个 .class 文件,我们需要将其装进一个类加载器中,它就像一个搬运工一样,会把所有的 .class 文件全部搬进JVM内存,转为Class对象。
重点知识:
Java文件经过编译后变成 .class 字节码文件
字节码文件通过类加载器被搬运到 JVM 虚拟机中
虚拟机主要的5大块:方法区,堆都为线程共享区域,有线程安全问题,栈和本地方法栈和计数器都是独享区域,不存在线程安全问题,而 JVM 的调优主要就是围绕堆,栈两大块进行。
类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。
在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
1)加载
查找并加载类的二进制数据加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
通过一个类的全限定名来获取其定义的二进制字节流。
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口。
加载class文件的方式:
1. 从本地系统中直接加载。
2. 通过网络获取,典型场景:Web Applet。
3. 从zip压缩包中读取,成为日后jar、war格式的基础。
4. 运行时计算生成,使用最多的是:动态代理技术。
5. 由其他文件生成,典型场景:JSP应用。
6. 从专有数据库中提取.class文件,比较少见。
7. 从加密文件中获取,典型的防Class文件被反编译的保护措施。
2)验证
确保被加载的类的正确性,不会危害虚拟机自身安全。
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object之外。
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
符号引用验证:确保解析动作能正确执行。
3)准备
为类的静态变量分配内存,并将其初始化为默认值,即零值。(是被static修饰的变量,不是final的不是常量,只有static修饰的)
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
这里不包含用final修饰的static,若是final的,则在编译期就会设置上最终值。
假设一个类变量的定义为:public static int value=3,那么变量value在准备阶段过后为其分配四个(int四个字节)字节的空间,并且设置初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,value赋值为3的动作将在初始化阶段才会执行。
注:不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到java堆中。总之,实例变量在堆内存中,而且实例变量是在对象初始化时才赋值。
4)解析
把类中常量池内的符号引用转换为直接引用(例如 import xxx.xxx.xxx 属于符号引用,而通过指针或者对象地址引用就是直接引用)。
符号引用就是一组符号来描述所引用的目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。
将符号引用转为直接引用的过程。在编译时,Java类不知道引用的类的实际地址,只能用符号引用来代替,类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为 JVM可以直接获取的内存地址或指针,即直接引用。
事实上,解析操作往往会伴随着JVM在执行完初始化(Initialization)之后再执行。
5)初始化
初始化其实就是一个赋值的操作,它会执行一个类构造器的方法。
由编译器自动收集类中所有变量的赋值动作,此时准备阶段时的那个static int a = 3 的例子,在这个时候就正式赋值为3。
初始化流程:
1. 初始化阶段就是执行类构造器方法
2. 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。若类中没有定义静态代码块或静态变量,则不会出现
3. 构造器方法中指令按语句在源文件中出现的顺序执行。
4.
5. 若该类具有父类,JVM会保证子类的
6. 虚拟机必须保证一个类的
7. 初始化会对变量进行赋值,即对最初的零值,进行显式初始化,例如 static int num = 0 变成了 static int num = 3 ,这些工作都会在类构造器
public class DeadThreadTest {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + "开始");
DeadThread dead = new DeadThread();
System.out.println(Thread.currentThread().getName() + "结束");
};
Thread t1 = new Thread(r,"线程1");
Thread t2 = new Thread(r,"线程2");
t1.start();
t2.start();
}
}
class DeadThread{
static{
if(true){
System.out.println(Thread.currentThread().getName() + "初始化当前类");
while(true){
}
}
}
}
输出结果:一个类只会被加载一次,只会被其中一个线程所执行,其中一个线程还没加载完,另一个线程因为同步加锁而不会加载。
6)卸载
GC将无用对象从内存中卸载,Java虚拟机将结束生命周期:
执行了 System.exit()方法
程序正常执行结束
程序在执行过程中遇到了异常或错误而异常终止
由于操作系统出现错误而导致Java虚拟机进程终止
类加载器会根据指定class文件的全限定名称,将其加载到JVM内存,转为Class对象。
类加载器子系统负责从文件系统或网络中加载Class文件,class文件在文件开头有特定的文件标识。
ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
加载的类信息存放于一块称位方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)
1. class file 存在于本地磁盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来根据这个文件实例化出n个一模一样的实例。
2. class file 加载到JVM中,被称为DNA元数据模板,放在方法区。
3. 在.class文件 -> JVM -> 最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader),扮演一个快递员的角色。
ClassLoader类 介绍:
ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)
方法名称 | 描述 |
---|---|
gatParent() | 返回该类加载器的超类加载器 |
loadClass(String name) | 加载名称为name的类,返回结果为java.lang.Class类的实例 |
findClass(String name) | 查找名称为name的类,返回结果为java.lang.Class类的实例 |
findLoadedClass(String name) | 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例 |
defineClass(String name,byte[] b,int off,int len) | 把字节数组b中的内容转换为一个Java类,返回结果为java。lang.Class类的实例 |
resolveClass(Class> c) | 连接指定的一个Java类 |
JVM支持两种类型的类加载器,分别是引导类加载器Bootstrap ClassLoader和自定义类加载器User-Defined ClassLoader。
从概念上来讲,自定义类加载器一般指的是程序中开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。
无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个,如图:
用户自定义类使用的是系统类加载器进行加载。
java的核心类库都是使用引导类加载器进行加载的,例如String类,通过getClassLoader()方法不能获取到引导类加载器获取结果为null,引导类加载器是c/c++语言实现的。
1)启动类加载器(引导类加载器Bootstrap ClassLoader)
1. 这个类加载器使用c/c++语言实现的,嵌套在JVM内部。
2. 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类。
3. 并不继承自java.lang.ClassLoader,没有父加载器。
4. 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
5. 处于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类。
2)扩展类加载器(Extension ClassLoader)
1. Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
2. 派生于ClassLoader类 3.父类加载器为启动类加载器
4. 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
3)应用程序加载器(系统类加载器 AppClassLoader)
1. java语言编写,由sun.misc.Launcher$AppClassLoader实现
2. 派生于ClassLoader类
3. 父类加载器为扩展类加载器
4. 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
5. 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
6. 通过ClassLader#getSystemClassLoader()方法可以获取到该类加载器
4)用户自定义类加载器(User ClassLoader )
除了上面三个自带的以外,用户还能制定自己的类加载器,但是所有自定义的类加载器都应该继承自java.lang.ClassLoader。比如热部署、tomcat都会用到自定义类加载器。
在java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。
为什仫要自定义类加载器?
1. 隔离加载类
2. 修改类加载的方式
3. 扩展加载源
4. 防止源码泄露
用户自定义类加载器实现步骤:
1. 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求。
2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中。
3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
加载一个Class类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的:
BootStrap ClassLoader:rt.jar
Extention ClassLoader: 加载扩展的jar包
App ClassLoader:指定的classpath下面的jar包
Custom ClassLoader:自定义的类加载器
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
工作原理:
Bootstrap ClassLoader,只要加载完成就会返回结果,如果顶层父类加载器无法加载此class,则会返回去交给子类加载器去尝试加载,若最底层的子类加载器也没找到,则会抛出ClassNotFoundException。
双亲委派模型的工作流程是:
如果一个类加载器收到了类加载的请求,他首先会从自己缓存里查找是否之前加载过这个class,加载过直接返回,没加载过的话他不会自己亲自去加载,而是把请求委托给父加载器去完成,类似递归,一直递归到顶层父类。因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
双亲委派机制:
当 AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
当 ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
如果 BootStrapClassLoader加载失败(例如在 $JAVA_HOME/jre/lib里未查找到该class),会使用 ExtClassLoader来尝试加载;
若ExtClassLoader也加载失败,则会使用 AppClassLoader来加载,如果 AppClassLoader也加载失败,则会报出异常 ClassNotFoundException。
源代码都在java.lang.ClassLoader中的loadClass方法之中,代码如下:
protected Class> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 首先,检查请求的类是否已经加载过
Class> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出 ClassNotFoundException
// 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载的时候
// 再调用本身的findClass方法来进行加载
long t1 = System.nanoTime();
c = findClass(name);
// 这是定义类装入器;记录数据
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass方法, 如父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException 异常后,再调用自己的findClass方法进行加载。
1.如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
2.如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终到达顶层启动类加载器;
3.如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派机制。
父类判断自己是否可以加载的条件就是类加载器分类中,每个加载器加载的包不同,例如启动类加载器加载java、javax等包的类,扩展类加载器可以加载ext目录下的类。
双亲委派机制优势:
1. 避免类的重复加载。
2. 保护程序安全,防止核心API被随意篡改,如:
定义与核心库中相同包路径且同名的类Stirng(报错:找不到主程序)
package java.lang;
/*
自定义包java.lang
创建与核心库中相同的类名String
*/
public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
/*
* 错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application
Process finished with exit code 1
* */
public static void main(String[] args) {
System.out.println("hello,String");
}
}
定义与核心库中相同包路径但类名不同的类(报错:安全问题)
package java.lang;
public class StringExt {
/*
* java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662)
at java.lang.ClassLoader.defineClass(ClassLoader.java:761)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main"
Process finished with exit code 1
*/
public static void main(String[] args) {
System.out.println("hello!");
}
}
自定义Stirng类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
在JVM中表示两个class对象是否为同一个类,存在两个必要条件:
1. 类的完整类名必须一致,包括包名。
2. 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。
换句话说,在JVM中,即使这两个对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
JVM必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
类的主动使用和被动使用:
java程序对类的使用方式分为:主动使用和被动使用。
主动使用,又分为七种情况:
1. 创建类的实例。
2. 访问某个类或接口的静态变量,或者对该静态变量赋值。
3. 调用类的静态方法。
4. 反射(比如:Class.forName("com.leizi.Test"))。
5. 初始化一个类的子类。
6. Java虚拟机启动时被标明为启动类的类。
7. JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果。
8. REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化。
除了以上七种情况,其他使用Java类的方式都被看做是对类的被动使用都不会导致类的初始化。
虽然jvm调优成熟的工具已经有很多,但是所有的工具几乎都是依赖于jdk的接口和底层的这些命令,研究这些命令的使用也让我们更能了解jvm构成和特性。
Sun JDK监控和故障处理命令有jps、jstat、jmap、jhat、jstack、jinfo,下面做一一介绍。
JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。
命令格式:
jps [options] [hostid]
option参数:
-l : 输出主类全名或jar路径
-q : 只输出LVMID
-m : 输出JVM启动时传递给main()的参数
-v : 输出JVM启动时显示指定的JVM参数
示例:
82518 sun.tools.jps.Jps -l -m
69981 /Users/mengloulv/Library/Application Support/JetBrains/IntelliJIdea2020.1/plugins/idea-spring-tools/lib/server/language-server.jar
79213 org.apache.catalina.startup.Bootstrap start
79212 org.jetbrains.jps.cmdline.Launcher /Applications/IntelliJ IDEA.app/Contents/lib/asm-all-7.0.1.jar:xxx...
jstat(JVM statistics Monitoring)是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
详见:https://docs.oracle.com/javase/7/docs/technotes/tools/share/jstat.html#gcnew_option
jstat命令命令格式:
jstat [Options] vmid [interval] [count]
命令参数说明:
Options,一般使用 -gcutil 或 -gc 查看gc 情况
pid,当前运行的 java进程号
interval,间隔时间,单位为秒或者毫秒
count,打印次数,如果缺省则打印无数次
option参数:
-gc:统计 jdk gc时 heap信息,以使用空间字节数表示
-gcutil:统计 gc时, heap情况,以使用空间的百分比表示
-class:统计 class loader行为信息
-compile:统计编译行为信息
-gccapacity:统计不同 generations(新生代,老年代,持久代)的 heap容量情况
-gccause:统计引起 gc的事件
-gcnew:统计 gc时,新生代的情况
-gcnewcapacity:统计 gc时,新生代 heap容量
-gcold:统计 gc时,老年代的情况
-gcoldcapacity:统计 gc时,老年代 heap容量
-gcpermcapacity:统计 gc时, permanent区 heap容量
示例1:每5秒一次显示进程号为15的java进程的GC情况,每5S生成异常,一共生成5次。
jstat -gc 15 5000 5
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
307200.0 307200.0 24333.5 0.0 2457600.0 945456.5 5120000.0 3462367.2 241360.0 209218.1 26120.0 20538.3 6642 531.164 20 6.874 538.038
307200.0 307200.0 24333.5 0.0 2457600.0 945513.4 5120000.0 3462367.2 241360.0 209218.1 26120.0 20538.3 6642 531.164 20 6.874 538.038
307200.0 307200.0 24333.5 0.0 2457600.0 968130.4 5120000.0 3462367.2 241360.0 209218.1 26120.0 20538.3 6642 531.164 20 6.874 538.038
307200.0 307200.0 24333.5 0.0 2457600.0 1394418.3 5120000.0 3462367.2 241360.0 209218.1 26120.0 20538.3 6642 531.164 20 6.874 538.038
307200.0 307200.0 24333.5 0.0 2457600.0 1867238.5 5120000.0 3462367.2 241360.0 209218.1 26120.0 20538.3 6642 531.164 20 6.874 538.038
说明:
S0C:第一个幸存区的大小
S1C:第二个幸存区的大小
S0U:第一个幸存区的使用大小
S1U:第二个幸存区的使用大小
EC:伊甸园区的大小
EU:伊甸园区的使用大小
OC:老年代大小
OU:老年代使用大小
MC:方法区大小
MU:方法区使用大小
CCSC:压缩类空间大小
CCSU:压缩类空间使用大小
YGC:年轻代垃圾回收次数
YGCT:年轻代垃圾回收消耗时间
FGC:老年代垃圾回收次数
FGCT:老年代垃圾回收消耗时间
GCT:垃圾回收消耗总时间
单位:KB
我可以计算出如下核心数据:
第一个幸存区的大小S0C:300M
第二个幸存区的大小S1C:300M
伊甸园区的大小EC:2400M
老年代大小OC:5000M
方法区大小MC:236M
年轻代垃圾回收消耗时间YGCT:531.164(单位?)
老年代垃圾回收消耗时间FGCT:6.874(单位?)
我们再看输出的GC日志:
Heap before GC invocations=6641 (full 10):
par new generation total 2764800K, used 2492979K [0x00000005cc000000, 0x0000000687800000, 0x0000000687800000)
eden space 2457600K, 100% used [0x00000005cc000000, 0x0000000662000000, 0x0000000662000000)
from space 307200K, 11% used [0x0000000674c00000, 0x0000000676e8cc90, 0x0000000687800000)
to space 307200K, 0% used [0x0000000662000000, 0x0000000662000000, 0x0000000674c00000)
concurrent mark-sweep generation total 5120000K, used 3462278K [0x0000000687800000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 209218K, capacity 229352K, committed 241360K, reserved 1265664K
class space used 20538K, capacity 24038K, committed 26120K, reserved 1048576K
343501.719: [GC (Allocation Failure) 343501.719: [ParNew: 2492979K->24333K(2764800K), 0.0261186 secs] 5955257K->3486700K(7884800K), 0.0262698 secs] [Times: user=0.05 sys=0.01, real=0.03 secs]
可以计算出如下核心数据:
第一个幸存区的大小S0C:300M
第二个幸存区的大小S1C:300M
伊甸园区的大小EC:2400M
老年代大小OC:从这里计算不出来
方法区大小MC:从这里计算不出来
GC耗时:30ms
我配置的JAVA_OPTS参数如下:
-Xmx8000M -Xms8000M -Xmn3000M -XX:PermSize=1000M -XX:MaxPermSize=1000M -Xss256K -XX:+DisableExplicitGC -XX:SurvivorRatio=8 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0 -XX:+CMSClassUnloadingEnabled -XX:LargePageSizeInBytes=128M -XX:+UseFastAccessorMethods -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=69 -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+PrintClassHistogram -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC -Xloggc:/home/work/logs/applogs/gc.log -javaagent:/home/work/app/prometheus/jmx_prometheus_javaagent-0.12.0.jar=3010:/home/work/app/prometheus/jmx-exporter.yml
这里我有两个疑问:
疑问1:其中2764800/1024=2700M,这个2700M是什么呢?是EC+S0C,或者是EC+S1C么?即“新生代当前可以使用的容量”么?(因为每次S0C和S1C,只会使用其中一个)
疑问2:其中7884800K/1024=7700M,这个7700M是什么呢?是OC+EC+S0C,或者是EC+S1C么?即老年代 + “新生代当前可以使用的容量”么?
示例2:同-gc,不过还会输出Java堆各区域使用到的最大、最小空间。
jstat -gccapacity 15
NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC MCMN MCMX MC CCSMN CCSMX CCSC YGC FGC
819200.0 819200.0 819200.0 273024.0 273024.0 273152.0 5324800.0 5324800.0 5324800.0 5324800.0 0.0 1251328.0 223560.0 0.0 1048576.0 22716.0 174 8
说明:
NGCMN Minimum new generation capacity (KB).
NGCMX Maximum new generation capacity (KB).
NGC Current new generation capacity (KB).
S0C Current survivor space 0 capacity (KB).
S1C Current survivor space 1 capacity (KB).
EC Current eden space capacity (KB).
OGCMN Minimum old generation capacity (KB).
OGCMX Maximum old generation capacity (KB).
OGC Current old generation capacity (KB).
OC Current old space capacity (KB).
PGCMN Minimum permanent generation capacity (KB).
PGCMX Maximum Permanent generation capacity (KB).
PGC Current Permanent generation capacity (KB).
PC Current Permanent space capacity (KB).
YGC Number of Young generation GC Events.
FGC Number of Full GC Events.
示例3:同-gc,不过输出的是已使用空间占总空间的百分比。
jstat -gcutil 15
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.32 0.00 27.68 9.86 98.17 96.03 174 24.437 8 0.720 25.157
示例4:垃圾收集统计概述(同-gcutil),附加最近两次垃圾回收事件的原因。
jstat -gccause 15
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT LGCC GCC
0.32 0.00 43.15 9.86 98.17 96.03 174 24.437 8 0.720 25.157 Allocation Failure No GC
详见:https://docs.oracle.com/javase/7/docs/technotes/tools/share/jstat.html#gccause_option
说明:
LGCC:最近垃圾回收的原因
GCC:当前垃圾回收的原因
示例5:统计新生代的行为。
jstat -gcnew 15
S0C S1C S0U S1U TT MTT DSS EC EU YGC YGCT
273024.0 273024.0 862.5 0.0 15 15 136512.0 273152.0 119572.5 174 24.437
详见:https://docs.oracle.com/javase/7/docs/technotes/tools/share/jstat.html#gcnew_option
示例6:统计新生代的行为。
jstat -gcold 15
MC MU CCSC CCSU OC OU YGC FGC FGCT GCT
223560.0 219467.9 22716.0 21813.1 5324800.0 524929.7 174 8 0.720 25.157
详见:https://docs.oracle.com/javase/7/docs/technotes/tools/share/jstat.html#gcold_option
示例 7:监视类装载、卸载数量、总空间以及耗费的时间。
jstat -class 15
Loaded Bytes Unloaded Bytes Time
39163 74053.8 11505 17286.0 46.52
说明:
Loaded : 加载class的数量
Bytes : class字节大小
Unloaded : 未加载class的数量
Bytes : 未加载class的字节大小
Time : 加载时间
示例8:输出JIT编译过的方法数量耗时等。
jstat -compiler 15
Compiled Failed Invalid Time FailedType FailedMethod
53393 4 0 575.86 1 com/mysql/jdbc/AbandonedConnectionCleanupThread run
说明:
Compiled : 编译数量
Failed : 编译失败数量
Invalid : 无效数量
Time : 编译耗时
FailedType : 失败类型
FailedMethod : 失败方法的全限定名
示例1:dump堆到文件,format指定输出格式,live指明是活着的对象,file指定文件名。
jmap -dump:live,format=b,file=dump.hprof 15
Dumping heap to /Users/mengloulv/java-workspace/dump.hprof ...
Heap dump file created
dump.hprof这个后缀是为了后续可以直接用MAT(Memory Anlysis Tool)打开。
示例2:打印等待回收对象的信息。
jmap -finalizerinfo 15
Attaching to process ID 15, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.202-b08
Number of objects pending for finalization: 0
可以看到当前F-QUEUE队列中并没有等待Finalizer线程执行finalizer方法的对象。
示例3:打印heap的概要信息,GC使用的算法,heap的配置及wise heap的使用情况,可以用此来判断内存目前的使用情况以及垃圾回收情况,感觉这个非常使用!
jmap -heap 15
Attaching to process ID 15, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.202-b08
using parallel threads in the new generation.
using thread-local object allocation. //GC 方式
Concurrent Mark-Sweep GC
Heap Configuration: //堆内存初始化配置
MinHeapFreeRatio = 40 /对应jvm启动参数-XX:MinHeapFreeRatio设置JVM堆最小空闲比率(default 40)
MaxHeapFreeRatio = 70 //对应jvm启动参数 -XX:MaxHeapFreeRatio设置JVM堆最大空闲比率(default 70)
MaxHeapSize = 8388608000 (8000.0MB) //对应jvm启动参数-XX:MaxHeapSize=设置JVM堆的最大大小
NewSize = 3145728000 (3000.0MB) //对应jvm启动参数-XX:NewSize=设置JVM堆的‘新生代’的默认大小
MaxNewSize = 3145728000 (3000.0MB) /对应jvm启动参数-XX:MaxNewSize=设置JVM堆的‘新生代’的最大大小
OldSize = 5242880000 (5000.0MB) //对应jvm启动参数-XX:OldSize=:设置JVM堆的‘老生代’的大小
NewRatio = 2 //对应jvm启动参数-XX:NewRatio=:‘新生代’和‘老生代’的大小比率
SurvivorRatio = 8 //对应jvm启动参数-XX:SurvivorRatio=设置年轻代中Eden区与Survivor区的大小比值
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB //对应jvm启动参数-XX:MaxPermSize=:设置JVM堆的‘永生代’的最大大小
G1HeapRegionSize = 0 (0.0MB)
Heap Usage: //堆内存使用情况
New Generation (Eden + 1 Survivor Space):
capacity = 2831155200 (2700.0MB)
used = 1819140416 (1734.8674926757812MB)
free = 1012014784 (965.1325073242188MB)
64.2543515805845% used
Eden Space:
capacity = 2516582400 (2400.0MB)
used = 1795637848 (1712.4536972045898MB)
free = 720944552 (687.5463027954102MB)
71.35223738352458% used
From Space:
capacity = 314572800 (300.0MB)
used = 23502568 (22.413795471191406MB)
free = 291070232 (277.5862045288086MB)
7.471265157063802% used
To Space:
capacity = 314572800 (300.0MB)
used = 0 (0.0MB)
free = 314572800 (300.0MB)
0.0% used
concurrent mark-sweep generation:
capacity = 5242880000 (5000.0MB)
used = 3448095536 (3288.360153198242MB)
free = 1794784464 (1711.6398468017578MB)
65.76720306396484% used
101426 interned Strings occupying 10749600 bytes.
示例4:打印堆的对象统计,包括对象数、内存大小等等 (因为在dump:live前会进行full gc,如果带上live则只统计活对象,因此不加live的堆大小要大于加live堆的大小 ),仅打印前15行。
jmap -histo:live 15 | more
num #instances #bytes class name
----------------------------------------------
1: 938552 113143448 [C
2: 983711 31478752 java.util.HashMap$Node
3: 930339 22328136 java.lang.String
4: 61854 21628224 [B
5: 215981 19006328 java.lang.reflect.Method
6: 200183 18164992 [Ljava.lang.Object;
7: 121341 16297048 [Ljava.util.HashMap$Node;
8: 511306 12919376 [Ljava.lang.String;
9: 169168 9391000 [I
10: 165488 6619520 java.util.LinkedHashMap$Entry
11: 131563 6315024 org.hibernate.hql.internal.ast.tree.Node
12: 122202 5865696 java.util.HashMap
13: 320105 5121680 java.lang.Integer
14: 204087 4898088 java.util.ArrayList
15: 138888 4444416 java.util.concurrent.ConcurrentHashMap$Node
... ...
xml class name是对象类型,说明如下:
B byte
C char
D double
F float
I int
J long
Z boolean
[数组,如[I表示int[]
[L+类名 其他对象
jhat(JVM Heap Analysis Tool)命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析。
示例:
jhat dump.hprof
当执行完毕后:
可以通过Http://localhost:7000访问。
jstack用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。
如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。
另外,jstack工具还可以附属到正在运行的java程序中,看到当时运行的java程序的java stack和native stack的信息, 如果现在运行的java程序呈现hung的状态,jstack是非常有用的。
命令格式:
jstack [option] LVMID
option参数:
-F : 当正常输出请求不被响应时,强制输出线程堆栈
-l : 除堆栈外,显示关于锁的附加信息
-m : 如果调用到本地方法的话,可以显示C/C++的堆栈
示例:
jstack -l 5073|more
Jconsole(Java Monitoring and Management Console)是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控,是一个基于JMX(java management extensions)的GUI性能监测工具。jconsole使用jvm的扩展机制获取并展示虚拟机中运行的应用程序的性能和资源消耗等信息。
JConsole程序位于%JAVA_HOME%bin目录下,直接通过命令启动:
JConsole
概览:包括堆内存使用情况、线程、类、CPU使用情况四项信息的曲线图。
内存:主要展示了内存的使用情况,同时可以查看堆和非堆内存的变化值对比。
线程:相当于可视化的jstack命令,同时也可以点击“检测死锁”来检查线程之间是否有死锁的情况。
VM概要:展示JVM所有信息总览,包括基本信息、线程相关、堆相关、操作系统、VM参数等。
VisualVM(All-in-One Java Troubleshooting Tool)是功能最强大的运行监视和故障处理程序之一,曾经在很长一段时间内是Oracle官方主力发展的虚拟机故障处理工具。
相比一些第三方工具,VisualVM有一个很大的优点:不需要被监视的程序基于特殊Agent去运行,因此它的通用性很强,对应用程序实际性能的影响也较小,使得它可以直接应用在生产环境中。
VisualVM程序位于%JAVA_HOME%bin目录下,直接通过命令启动:
jvisualvm
可以安装插件,点击工具->插件:
Visual GC 是常常使用的一个功能,需要通过插件按照,可以明显的看到年轻代、老年代的内存变化,以及gc频率、gc的时间等,感觉这个插件很酷!
监控的主页其实也就是,cpu、内存、类、线程的图表,这里面可以执行堆dump。
线程和jconsole功能没有太大的区别。
最后就是堆dump:
和jconsole比起来,VisualVM用起来更方便,因为jconsole的功能,VisualVM基本都包含,然后VisualVM还可以分析dump文件,然后还有很多可选的插件,真香!
以上三个都是JDK自带的性能监控工具,除此之外还有一些第三方的性能监控工具。
MAT:Java 堆内存分析工具。
GChisto:GC 日志分析工具。
GCViewer:GC 日志分析工具。
JProfiler:商用的性能分析利器。
arthas:阿里开源诊断工具。
async:Java 应用性能分析工具,开源、火焰图、跨平台。
前面的示例都是在本地测试,这里我主要通过VisualVM图像化工具,看如何连接上线上机器。
1)通过jstatd启动RMI
先登录线上机器,执行"echo $JAVA_HOME"查看目录,然后进入该目录的bin目录下面:
在该目录下新建文件jstatd.all.policy,然后将下面的代码copy到该文件中:
grant codebase "file:${java.home}/../lib/tools.jar" {
permission java.security.AllPermission;
};
执行:
./jstatd -J-Djava.security.policy=jstatd.all.policy &
2)配置JMX管理Tomcat
先进入Tomcat的bin目录下面,然后打开catalina.sh文件,会发现这个文件前面都是一堆注释,注意!!!就在这一堆注释后面加上一行代码(其实也可以说,就在这个文件的第一行加上一行代码):
JAVA_OPTS=-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false
下来说说上面几个参数的意义:
-Dcom.sun.management.jmxremote :允许使用JMX远程管理
-Dcom.sun.management.jmxremote.port=9999 :JMX远程连接端口
-Dcom.sun.management.jmxremote.authenticate=false :不进行身份认证,任何用户都可以连接
-Dcom.sun.management.jmxremote.ssl=false :不使用ssl
3)远程机器视图
挂载远程机器:
配置端口:
界面输出:
这个方式其实是按照网上的教程操作的,我操作的是公司远程测试环境的机器,但是我操作过程中,其实没有走“配置端口”这一步,也就是只操作了“通过jstatd启动RMI”,就可以通过该工具显示JVM视图,感觉和网上说的不一样。
但是我操作线上机器时,这个JVM视图还是出不来,我理解如下:
测试环境之所以能展示JVM视图,是因为它走的HTTP,所以无需再配置JAVA_OPTS的jmxremote参数;
线上机器是HTTPS,是需要走“配置JMX管理Tomcat”,也就是在JAVA_OPTS中配置jmxremote的参数信息;
配置JAVA_OPTS不一定非要按照网上说的教程来,你也可以把JAVA_OPTS通过其它方式进行配置,比如我是通过公司自带的Docker容器进行配置;
4)HTTPS机器JVM图像化展示
对Tomcat远程监控,需要对远端的Tomcat和JDK进行配置。
1. 修改IP
我使用的Linux,使用hostname -i 查看ip是否为自己的外网IP(如果你仅仅是内网,是内网的IP,但不能是127.0.0.1或者localhost)。
# localhost是本地环回地址,lack为机器名,将localhost修改为对应ip
# 如:10.10.23.10 lack
localhost lack
2. 通过jstatd启动RMI
要通过远程访问jstatd需要进行授权,在JAVA_HOME/bin目录下创建jstatd.all.policy文件,内容如下:
grant codebase "file:${java.home}/../lib/tools.jar" {
permission java.security.AllPermission;
};
注意空格,最好直接复制。使用以下命令启动jstatd(注意:要在JAVA_HOME/bin目录下运行该命令)。
jstatd -J-Djava.security.policy=jstatd.all.policy &
这个时候在VisualVM里面的远程连接上右键->添加远程主机,输入IP就可以看到Jstatd了。
3. 配置JMX管理Tomcat
默认Tomcat只可以从本地使用JMX,进行远程管理需要进行以下配置。打开Tomcat下的bin/catalina.sh(windows下为bat)文件,在一堆注释后面添加一行:
JAVA_OPTS=-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false - Dcom.sun.management.jmxremote.ssl=false
然后在VisualVM里面右键添加JMX连接,输入IP和端口号9999(端口号可以自定义,但是必须保证没有被占用!!!)
好滴,到此就配置完成了,接下来说说上面几个参数的意义:
-Dcom.sun.management.jmxremote :允许使用JMX远程管理
-Dcom.sun.management.jmxremote.port=9008 :JMX远程连接端口
-Dcom.sun.management.jmxremote.authenticate=false :不进行身份认证,任何用户都可以连接
-Dcom.sun.management.jmxremote.ssl=false :不使用ssl
不使用ssl就勾选最后一个“不要求ssl连接”,因为没有身份证,所以也不勾选使用安全凭证。但是如果连接的是公网上的Tomcat,那么就要注意安全性了,接下来看看使用用户名和密码连接
依然是在catalina.sh里面,设置JAVA_OPTS:
JAVA_OPTS='-Xms128m -Xmx256m -XX:MaxPermSize=128m
-Djava.rmi.server.hostname=10.10.23.10
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=true
-Dcom.sun.management.jmxremote.password.file=/usr/java/default/jre/lib/management/jmxremote.password
-Dcom.sun.management.jmxremote.access.file=/usr/java/default/jre/lib/management/jmxremote.access'
/usr/java/default/jre/lib/management/jmxremote.password和/usr/java/default/jre/lib/management/jmxremote.access是两个文件(在JAVA_HOME\jre\lib\management下有模板),以下分别编辑两个文件:
# /usr/java/default/jre/lib/management/jmxremote.password
#(123456、123456789为密码)
monitorRole 123456
controlRole 123456789
# /usr/java/default/jre/lib/management/jmxremote.access
monitorRole readonly
controlRole readwrite
新建完成这两个文件要修改jmxremote.password的权限:
chmod 600 jmxremote.password
接下来就可以远程连接了。
JVM调优听起来很高大上,但是要认识到,JVM调优应该是Java性能优化的最后一颗子弹。
比较认可廖雪峰老师的观点,要认识到JVM调优不是常规手段,性能问题一般第一选择是优化程序,最后的选择才是进行JVM调优。
常用调优策略
这里还是要提一下,及时确定要进行JVM调优,也不要陷入“知见障”,进行分析之后,发现可以通过优化程序提升性能,仍然首选优化程序。
CPU单核,那么毫无疑问Serial 垃圾收集器是你唯一的选择。
CPU多核,关注吞吐量 ,那么选择PS+PO组合。
CPU多核,关注用户停顿时间,JDK版本1.6或者1.7,那么选择CMS。
CPU多核,关注用户停顿时间,JDK1.8及以上,JVM可用内存6G以上,那么选择G1。
参数配置:
//设置Serial垃圾收集器(新生代)
开启:-XX:+UseSerialGC
//设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器
开启 -XX:+UseParallelOldGC
//CMS垃圾收集器(老年代)
开启 -XX:+UseConcMarkSweepGC
//设置G1垃圾收集器
开启 -XX:+UseG1GC
现象:垃圾收集频率非常频繁。
原因:如果内存太小,就会导致频繁的需要进行垃圾收集才能释放出足够的空间来创建新的对象,所以增加堆内存大小的效果是非常显而易见的。
注意:如果垃圾收集次数非常频繁,但是每次能回收的对象非常少,那么这个时候并非内存太小,而可能是内存泄露导致对象无法回收,从而造成频繁GC。
参数配置:
//设置堆初始值
指令1:-Xms2g
指令2:-XX:InitialHeapSize=2048m
//设置堆区最大值
指令1:`-Xmx2g`
指令2: -XX:MaxHeapSize=2048m
//新生代内存配置
指令1:-Xmn512m
指令2:-XX:MaxNewSize=512m
现象:程序间接性的卡顿。
原因:如果没有确切的停顿时间设定,垃圾收集器以吞吐量为主,那么垃圾收集时间就会不稳定。
注意:不要设置不切实际的停顿时间,单次时间越短也意味着需要更多的GC次数才能回收完原有数量的垃圾。
参数配置:
//GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间
-XX:MaxGCPauseMillis
现象:某一个区域的GC频繁,其他都正常。
原因:如果对应区域空间不足,导致需要频繁GC来释放空间,在JVM堆内存无法增加的情况下,可以调整对应区域的大小比率。
注意:也许并非空间不足,而是因为内存泄造成内存无法回收。从而导致GC频繁。
参数配置:
//survivor区和Eden区大小比率
指令:-XX:SurvivorRatio=6 //S区和Eden区占新生代比率为1:6,两个S区2:6
//新生代和老年代的占比
-XX:NewRatio=4 //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2
现象:老年代频繁GC,每次回收的对象很多。
原因:如果升代年龄小,新生代的对象很快就进入老年代了,导致老年代对象变多,而这些对象其实在随后的很短时间内就可以回收,这时候可以调整对象的升级代年龄,让对象不那么容易进入老年代解决老年代空间不足频繁GC问题。
注意:增加了年龄之后,这些对象在新生代的时间会变长可能导致新生代的GC频率增加,并且频繁复制这些对象新生的GC时间也可能变长。
配置参数:
//进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,默认值7
-XX:InitialTenuringThreshol=7
现象:老年代频繁GC,每次回收的对象很多,而且单个对象的体积都比较大。
原因:如果大量的大对象直接分配到老年代,导致老年代容易被填满而造成频繁GC,可设置对象直接进入老年代的标准。
注意:这些大对象进入新生代后可能会使新生代的GC频率和时间增加。
配置参数:
//新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
-XX:PretenureSizeThreshold=1000000
现象:CMS,G1 经常 Full GC,程序卡顿严重。
原因:G1和CMS 部分GC阶段是并发进行的,业务线程和垃圾收集线程一起工作,也就说明垃圾收集的过程中业务线程会生成新的对象,所以在GC的时候需要预留一部分内存空间来容纳新产生的对象,如果这个时候内存空间不足以容纳新产生的对象,那么JVM就会停止并发收集暂停所有业务线程(STW)来保证垃圾收集的正常运行。这个时候可以调整GC触发的时机(比如在老年代占用60%就触发GC),这样就可以预留足够的空间来让业务线程创建的对象有足够的空间分配。
注意:提早触发GC会增加老年代GC的频率。
配置参数:
//使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
-XX:CMSInitiatingOccupancyFraction
//G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%
-XX:G1MixedGCLiveThresholdPercent=65
现象:GC的次数、时间和回收的对象都正常,堆内存空间充足,但是报OOM
原因: JVM除了堆内存之外还有一块堆外内存,这片内存也叫本地内存,可是这块内存区域不足了并不会主动触发GC,只有在堆内存区域触发的时候顺带会把本地内存回收了,而一旦本地内存分配不足就会直接报OOM异常。
注意: 本地内存异常的时候除了上面的现象之外,异常信息可能是OutOfMemoryError:Direct buffer memory。 解决方式除了调整本地内存大小之外,也可以在出现此异常时进行捕获,手动触发GC(System.gc())。
配置参数:
XX:MaxDirectMemorySize
以下是整理自网络的一些JVM调优实例:
1)网站流量浏览量暴增后,网站反应页面响很慢
问题推测:在测试环境测速度比较快,但是一到生产就变慢,所以推测可能是因为垃圾收集导致的业务线程停顿。
定位:为了确认推测的正确性,在线上通过jstat -gc 指令 看到JVM进行GC 次数频率非常高,GC所占用的时间非常长,所以基本推断就是因为GC频率非常高,所以导致业务线程经常停顿,从而造成网页反应很慢。
解决方案:因为网页访问量很高,所以对象创建速度非常快,导致堆内存容易填满从而频繁GC,所以这里问题在于新生代内存太小,所以这里可以增加JVM内存就行了,所以初步从原来的2G内存增加到16G内存。
第二个问题:增加内存后的确平常的请求比较快了,但是又出现了另外一个问题,就是不定期的会间断性的卡顿,而且单次卡顿的时间要比之前要长很多。
问题推测:练习到是之前的优化加大了内存,所以推测可能是因为内存加大了,从而导致单次GC的时间变长从而导致间接性的卡顿。
定位:还是通过jstat -gc 指令 查看到 的确FGC次数并不是很高,但是花费在FGC上的时间是非常高的,根据GC日志 查看到单次FGC的时间有达到几十秒的。
解决方案: 因为JVM默认使用的是PS+PO的组合,PS+PO垃圾标记和收集阶段都是STW,所以内存加大了之后,需要进行垃圾回收的时间就变长了,所以这里要想避免单次GC时间过长,所以需要更换并发类的收集器,因为当前的JDK版本为1.7,所以最后选择CMS垃圾收集器,根据之前垃圾收集情况设置了一个预期的停顿的时间,上线后网站再也没有了卡顿问题。
2)后台导出数据引发的OOM
**问题描述:**公司的后台系统,偶发性的引发OOM异常,堆内存溢出。
两种内存溢出异常,注意内存溢出是error级别的:
StackOverFlowError:当请求的栈深度大于虚拟机所允许的最大深度。
OutOfMemoryError:虚拟机在扩展栈时无法申请到足够的内存空间,一般都能设置扩大。
因为是偶发性的,所以第一次简单的认为就是堆内存不足导致,所以单方面的加大了堆内存从4G调整到8G。
但是问题依然没有解决,只能从堆内存信息下手,通过开启了-XX:+HeapDumpOnOutOfMemoryError参数 获得堆内存的dump文件。
VisualVM 对 堆dump文件进行分析,通过VisualVM查看到占用内存最大的对象是String对象,本来想跟踪着String对象找到其引用的地方,但dump文件太大,跟踪进去的时候总是卡死,而String对象占用比较多也比较正常,最开始也没有认定就是这里的问题,于是就从线程信息里面找突破点。
通过线程进行分析,先找到了几个正在运行的业务线程,然后逐一跟进业务线程看了下代码,发现有个引起我注意的方法,导出订单信息。
因为订单信息导出这个方法可能会有几万的数据量,首先要从数据库里面查询出来订单信息,然后把订单信息生成excel,这个过程会产生大量的String对象。
为了验证自己的猜想,于是准备登录后台去测试下,结果在测试的过程中发现到处订单的按钮前端居然没有做点击后按钮置灰交互事件,结果按钮可以一直点,因为导出订单数据本来就非常慢,使用的人员可能发现点击后很久后页面都没反应,结果就一直点,结果就大量的请求进入到后台,堆内存产生了大量的订单对象和EXCEL对象,而且方法执行非常慢,导致这一段时间内这些对象都无法被回收,所以最终导致内存溢出。
知道了问题就容易解决了,最终没有调整任何JVM参数,只是在前端的导出订单按钮上加上了置灰状态,等后端响应之后按钮才可以进行点击,然后减少了查询订单信息的非必要字段来减少生成对象的体积,然后问题就解决了。
补充:
java -verbose:class -version:可以查看刚开始加载的类,可以发现这两个类并不是异常出现的时候才去加载,而是jvm启动的时候就已经加载。这么做的原因是在vm启动过程中我们把类加载起来,并创建几个没有堆栈的对象缓存起来,只需要设置下不同的提示信息即可,当需要抛出特定类型的OutOfMemoryError异常的时候,就直接拿出缓存里的这几个对象就可以了。
OutOfMemoryError对象:jvm预留出4个对象【固定常量】,这就为什么最多出现4次有堆栈的OutOfMemoryError异常及大部分情况下都将看到没有堆栈的OutOfMemoryError对象的原因。
3)单个缓存数据过大导致的系统CPU飚高
系统发布后发现CPU一直飚高到600%,发现这个问题后首先要做的是定位到是哪个应用占用CPU高,通过top 找到了对应的一个java应用占用CPU资源600%。
如果是应用的CPU飚高,那么基本上可以定位可能是锁资源竞争,或者是频繁GC造成的。
所以准备首先从GC的情况排查,如果GC正常的话再从线程的角度排查,首先使用jstat -gc PID 指令打印出GC的信息,结果得到得到的GC 统计信息有明显的异常,应用在运行了才几分钟的情况下GC的时间就占用了482秒,那么问这很明显就是频繁GC导致的CPU飚高。
定位到了是GC的问题,那么下一步就是找到频繁GC的原因了,所以可以从两方面定位了,可能是哪个地方频繁创建对象,或者就是有内存泄露导致内存回收不掉。
根据这个思路决定把堆内存信息dump下来看一下,使用jmap -dump 指令把堆内存信息dump下来(堆内存空间大的慎用这个指令否则容易导致会影响应用,因为我们的堆内存空间才2G所以也就没考虑这个问题了)。
把堆内存信息dump下来后,就使用visualVM进行离线分析了,首先从占用内存最多的对象中查找,结果排名第三看到一个业务VO占用堆内存约10%的空间,很明显这个对象是有问题的。
通过业务对象找到了对应的业务代码,通过代码的分析找到了一个可疑之处,这个业务对象是查看新闻资讯信息生成的对象,由于想提升查询的效率,所以把新闻资讯保存到了redis缓存里面,每次调用资讯接口都是从缓存里面获取。
把新闻保存到redis缓存里面这个方式是没有问题的,有问题的是新闻的50000多条数据都是保存在一个key里面,这样就导致每次调用查询新闻接口都会从redis里面把50000多条数据都拿出来,再做筛选分页拿出10条返回给前端。50000多条数据也就意味着会产生50000多个对象,每个对象280个字节左右,50000个对象就有13.3M,这就意味着只要查看一次新闻信息就会产生至少13.3M的对象,那么并发请求量只要到10,那么每秒钟都会产生133M的对象,而这种大对象会被直接分配到老年代,这样的话一个2G大小的老年代内存,只需要几秒就会塞满,从而触发GC。
知道了问题所在后那么就容易解决了,问题是因为单个缓存过大造成的,那么只需要把缓存减小就行了,这里只需要把缓存以页的粒度进行缓存就行了,每个key缓存10条作为返回给前端1页的数据,这样的话每次查询新闻信息只会从缓存拿出10条数据,就避免了此问题的 产生。
4)CPU经常100% 问题定位
问题分析:CPU高一定是某个程序长期占用了CPU资源。
所以先需要找出那个进行占用CPU高:
top 列出系统各个进程的资源占用情况。
然后根据找到对应进行里哪个线程占用CPU高:
top -Hp 进程ID 列出对应进程里面的线程占用资源情况
找到对应线程ID后,再打印出对应线程的堆栈信息:
printf "%x\n" PID 把线程ID转换为16进制。
jstack PID 打印出进程的所有线程信息,从打印出来的线程信息中找到上一步转换为16进制的线程ID对应的线程信息。
最后根据线程的堆栈信息定位到具体业务方法,从代码逻辑中找到问题所在:
查看是否有线程长时间的watting 或blocked。
如果线程长期处于watting状态下, 关注watting on xxxxxx,说明线程在等待这把锁,然后根据锁的地址找到持有锁的线程。
5)内存飚高问题定位
分析: 内存飚高如果是发生在java进程上,一般是因为创建了大量对象所导致,持续飚高说明垃圾回收跟不上对象创建的速度,或者内存泄露导致对象无法回收。
先观察垃圾回收的情况:
jstat -gc PID 1000 查看GC次数,时间等信息,每隔一秒打印一次。
jmap -histo PID | head -20 查看堆内存占用空间最大的前20个对象类型,可初步查看是哪个对象占用了内存。
如果每次GC次数频繁,而且每次回收的内存空间也正常,那说明是因为对象创建速度快导致内存一直占用很高;如果每次回收的内存非常少,那么很可能是因为内存泄露导致内存一直无法被回收。
导出堆内存文件快照:
jmap -dump:live,format=b,file=/home/myheapdump.hprof PID dump堆内存信息到文件。
使用visualVM对dump文件进行离线分析,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。
6)数据分析平台系统频繁 Full GC
平台主要对用户在 App 中行为进行定时分析统计,并支持报表导出,使用 CMS GC 算法。
数据分析师在使用中发现系统页面打开经常卡顿,通过 jstat 命令发现系统每次 Young GC 后大约有 10% 的存活对象进入老年代。
原来是因为 Survivor 区空间设置过小,每次 Young GC 后存活对象在 Survivor 区域放不下,提前进入老年代。
通过调大 Survivor 区,使得 Survivor 区可以容纳 Young GC 后存活对象,对象在 Survivor 区经历多次 Young GC 达到年龄阈值才进入老年代。
调整之后每次 Young GC 后进入老年代的存活对象稳定运行时仅几百 Kb,Full GC 频率大大降低。
7)业务对接网关 OOM
网关主要消费 Kafka 数据,进行数据处理计算然后转发到另外的 Kafka 队列,系统运行几个小时候出现 OOM,重启系统几个小时之后又 OOM。
通过 jmap 导出堆内存,在 eclipse MAT 工具分析才找出原因:代码中将某个业务 Kafka 的 topic 数据进行日志异步打印,该业务数据量较大,大量对象堆积在内存中等待被打印,导致 OOM。
8)鉴权系统频繁长时间 Full GC
系统对外提供各种账号鉴权服务,使用时发现系统经常服务不可用,通过 Zabbix 的监控平台监控发现系统频繁发生长时间 Full GC,且触发时老年代的堆内存通常并没有占满,发现原来是业务代码中调用了 System.gc()。
GC是Garbage Collection,即垃圾回收。
新生成的对象首先存放在生成区,当生成区满了,触发Minor GC,存活下来的对象转移到Survivor0,即FromSpace,Survivor0区满后触发执行Minor GC,存活对象移动到Suvivor1区,即ToSpace,经过多次Minor GC仍然存活的对象转移到老年代。
所以老年代存储的是长期活动的对象,当老年代满了会触发Major GC。
Minor GC和Major GC是俗称,有些情况下Major GC和Full GC是等价的,如果出发了Full GC,那么所有线程必须等待GC完成才能继续(见GC分类和算法)。
将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。并针对分类进行不同的垃圾回收算法,对算法扬长避短。
为什么幸存区分为大小相同的两部分:S0,S1
主要为了解决碎片化,因为回收一部分对象后,剩余对象占用的内存不连续,也就是碎片化,过于严重的话,当前连续的内存不够新对象存放就会触发GC,这样会提高GC的次数,降低性能,当S0 GC后存活对象转移到S1后存活对象占用的就是连续的内存。
发现线上的2台机器一直在GC,而且频率非常高,下面是GC的日志:
由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Scavenge GC 和 Full GC 。
Scavenge GC
一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到老一代。
因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden区能尽快空闲出来。
Full GC
对整个堆进行整理,包括年轻代,老一代和持久代。Full GC 因为需要对整个堆进行回收,所以比 Scavenge GC 要慢,因此应该尽可能减少 Full GC 的次数。在对JVM调优的过程中,很大一部分工作就是对于 Full GC 的调节。
有如下原因可能导致Full GC:
. 老一代被写满;
. 持久代被写满;
. System.gc()被显式调用;
. 上一次GC之后Heap的各域分配策略动态变化;
通常,判断一个对象是否被销毁有两种方法:
引用计数算法:为对象添加一个引用计数器,每当对象在一个地方被引用,则该计数器加1;每当对象引用失效时,计数器减1。但计数器为0的时候,就表白该对象没有被引用。
可达性分析算法:通过一系列被称之为“GC Roots”的根节点开始,沿着引用链进行搜索,凡是在引用链上的对象都不会被回收。
就像上图的那样,绿色部分的对象都在GC Roots的引用链上,就不会被垃圾回收器回收,灰色部分的对象没有在引用链上,自然就被判定为可回收对象。
那么,问题来了,这个GC Roots又是什么?下面列举可以作为GC Roots的对象:
Java虚拟机栈中被引用的对象,各个线程调用的参数、局部变量、临时变量等。
方法区中类静态属性引用的对象,比如引用类型的静态变量。
方法区中常量引用的对象。
本地方法栈中所引用的对象。
Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象。
被同步锁(synchronized)持有的对象。
1)标记--清除算法
见名知义,标记--清除算法就是对无效的对象进行标记,然后清除。
对于标记--清除算法,你一定会清楚看到,在进行垃圾回收之后,堆空间有大量的碎片,出现了不规整的情况。在给大对象分配内存的时候,由于无法找到足够的连续的内存空间,就不得不再一次触发垃圾收集。另外,如果Java堆中存在大量的垃圾对象,那么垃圾回收的就必然进行大量的标记和清除动作,这个势必造成回收效率的降低。
2)复制算法
标记--复制算法就是把Java堆分成两块,每次垃圾回收时只使用其中一块,然后把存活的对象全部移动到另一块区域。
标记--复制算法有一个很明显的缺点,那就是每次只使用堆空间的一半,造成了Java堆空间使用率的的下降。
3)标记--整理算法
标记--整理算法算是一种折中的垃圾收集算法,在对象标记的过程,和前面两个执行的是一样步骤。但是,进行标记之后,存活的对象会移动到堆的一端,然后直接清理存活对象以外的区域就可以了。这样,既避免了内存碎片,也不存在堆空间浪费的说法了。但是,每次进行垃圾回收的时候,都要暂停所有的用户线程,特别是对老年代的对象回收,则需要更长的回收时间,这对用户体验是非常不好的。
在HotSpot虚拟机里面实现了七种作用于不同分代的收集器。
如果两个收集器之间存在连线,就说明它们可以搭配使用 ,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。
虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来,虽然垃圾收集器的技术在不断进步,但直到现在还没有 最好的收集器出现,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器。
Serial收集器是最基础、历史最悠久的收集器,是一个单线程工作的收集器,使用 Serial收集器,无论是进行 Minor gc 还是 Full GC ,清理堆空间时,所有的应用线程都会被暂停。进行Full GC时,它还会对老年代空间的对象进行压缩整理。通过 -XX:+UseSerialgGC 标志可以启用 Serial收集器。3
对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致。
ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。是JDK 7之前的遗留系统中首选的新生代收集器。
Parallel Scavenge收集器也是一款新生代收集器,基于标记——复制算法实现,能够并行收集的多线程收集器和 ParNew 非常相似。
Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数和直接设置吞吐量大小的**-XX:GCTimeRatio** 参数。
4)Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
在JDK8里面默认垃圾收集器是 UseParallelGC 即 Parallel Scavenge + Parallel Old 。使用 java -XX:+PrintCommandLineFlags -version 命令可以查看
CMS 收集器设计的初衷是为了消除 Parallel 收集器和 Serial 收集器 Full gc 周期中的长时间停顿。CMS收集器在 Minor gc 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。CMS收集器基于标记-清除算法实现的,整个过程分为四个步骤, 整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
垃圾回收过程如下:
初始标记(CMS initial mark):初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;仍然需要“Stop The World”。
并发标记(CMS concurrent mark):并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
重新标记(CMS remark):重新标记阶段则是为了修正并发标记期间,因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
并发清除(CMS concurrent sweep):并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
7)Garbage First收集器(G1)
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么为整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1垃圾收集器使用Mixed GC模式可以面向堆内存任何部分来组成回收集(Collection Set,一般简称为Cset)进行回收,衡量标准不再是它属于哪个年代,而是哪块内存中存放的垃圾数最多,回收收益最大。
G1基于Region的堆布局时它能够实现这个目标的关键。虽然G1仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为大小相等的独立区域(Region),且每一个Region都可以根据需要扮演新生代的Eden空间,Survivor空间或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活一段时间,熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且为2的N次幂。而对于那些超过整个Region容量的超级大对象,将会被存放N个连续的Humongous Region中,G1的大多数行为都把HumonGous Region作为老年代的一部分来进行看待。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列无序连续区域的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单词回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
更具体的思路为让G1收集器去跟踪各个Region中的垃圾堆积的"价值"大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后再后台维护一个有限级列表,每次根据用户设定的收集停顿时间(通过-XX:MaxGCPauseMillis指定,默认值为200毫秒),优先处理回收价值收益最大的Region,这也是"Garbage First"名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式保证了G1收集器在有限的时间内获取尽可能高的收集效率。
收集器的运作过程:
初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一个阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时比较短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记(Concurrent Marking):从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时比较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
筛选回收(Live Data Counting and Evacuation): 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
HotSpot VM中的垃圾回收器,以及适用场景:
下面是另外一个网友给出的各垃圾收集器对比:
垃圾收集器简单概括:
新生代用“复制算法”,老年代基本用“标记-整理”算法,有的也用“标记-清除”算法(新生代因为有surive区域,所以肯定使用的“复制算法”,老年代不可能划分成2个区域,所以肯定不会使用“复制算法”);
单线程垃圾回收器:Serial、Serial Old;
多线程垃圾回收器:ParNew、Parallel Old、Pararrel Scavenge和G1;
适用新生代的垃圾回收器:Serial、ParNew、Pararrel Scavenge和G1;
适用老年代的垃圾回收器:Serial Old、Parallel Old和G1。
Minor GC/Young GC:针对新生代的垃圾收集;
Major GC/Old GC:针对老年代的垃圾收集。
Full GC:针对整个Java堆以及方法区的垃圾收集。
1)Minor GC工作原理
通常情况下,初次被创建的对象存放在新生代的Eden区,当第一次触发Minor GC,Eden区存活的对象被转移到Survivor区的某一块区域。以后再次触发Minor GC的时候,Eden区的对象连同一块Survivor区的对象一起,被转移到了另一块Survivor区。可以看到,这两块Survivor区我们每一次只使用其中的一块,这样也仅仅是浪费了一块Survivor区。
要注意的2点:
每经历过一次垃圾回收的对象,它的分代年龄就加1,当分代年龄达到15以后,就直接被存放到老年代中。
给大对象分配内存的时候,Eden区已经没有足够的内存空间了,大对象就会直接进入老年代。
2)Full GC工作原理
老年代是存储长期存活的对象的,占满时就会触发我们最常听说的Full GC,期间会停止所有线程等待GC的完成。所以对于响应要求高的应用应该尽量去减少发生Full GC从而避免响应超时的问题。
需要注意的几点:
Full GC耗时较长,发生次数远没有Minor GC频繁,太频繁意味着性能出现问题。
标记-清除算法会产生大量内存碎片,以后如果需要为大对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次GC回收操作。
无论是Minor GC,还是Full GC,都会产生停顿现象,即Stop-The-World。Minor GC停顿时间较短,而Full GC耗时较长将导致长时间停顿、系统无响应,极大影响系统的性能。因此,Full GC日志的监控和性能分析在性能优化中极为重要。
1)GC日志开启
偷个懒,直接贴网上的内容:
2)理解GC日志
Minor GC日志:
Full GC日志:
其实还有一些打印及CMS方面的参数,这里就不以一一列举了。
线上机器配置:
内存是16G
cpu 4核
优化前
再回到我们刚开始的截图:
通过分析和计算,可以得到如下数据:
老生代:5870976/(1024*1024) = 5.6G
新生代:546176/1024 = 533M
Eden:273152/1024 = 266M
From:273024/1024 = 266M
To:273024/1024 = 266M
得出如下结论:
新生代+老生代 = 5.6 + 533/1024 = 6.1G
新生代:老生代 = 533 : (5.6*1024) = 1 : 10.7
Edem:From:To = 1:1:1
我们再看一下线上的配置:
通过该配置再验证刚才的计算结果:
“-Xmx6000M -Xms6000M”,可以确定JVM内存大小为6000/1024=5.8G,之前计算的堆内存大小为6.1G,基本匹配(多余的可能分配给了永生代)
“ -Xmn800M”,可以确定新生代是800M,Edem+From+To为798M,基本匹配(为什么新生代533M和“Edem+From+To”798M有出入呢?)
“XX:SurvivorRatio=1”,这里有一个计算公式,大家可以自己百度一下,通过公式得到的结论是Edem:From:To = 1:1:1,和我们的计算结果完全匹配。
优化后
需要优化的点:
目前内存使用不到一半,需要调整JVM内存大小;
Edem的内存太小,只有266M,这个是频繁Minor GC的主要原因,需要扩大改值;
新生代:老生代的比值,需要从之前的1 : 10.7,调整到1:2
新生代的Edem:From:To比值,需要从之前的1:1:1,调整到8:1:1
优化后的配置:
优化后的线上日志:
Heap before GC invocations=3 (full 1):
par new generation total 2764800K, used 2524705K [0x00000005cc000000, 0x0000000687800000, 0x0000000687800000)
eden space 2457600K, 100% used [0x00000005cc000000, 0x0000000662000000, 0x0000000662000000)
from space 307200K, 21% used [0x0000000674c00000, 0x0000000678d885c0, 0x0000000687800000)
to space 307200K, 0% used [0x0000000662000000, 0x0000000662000000, 0x0000000674c00000)
concurrent mark-sweep generation total 5120000K, used 15613K [0x0000000687800000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 62116K, capacity 62680K, committed 63288K, reserved 1105920K
class space used 6639K, capacity 6781K, committed 6816K, reserved 1048576K
35.225: [GC (Allocation Failure) 35.225: [ParNew: 2524705K->184767K(2764800K), 0.2682475 secs] 2540319K->200381K(7884800K), 0.2683305 secs] [Times: user=1.05 sys=0.00, real=0.27 secs]
优化后的结果:
JVM内存大小为10000M,约9.7G
Edem的内存大小为2.6G,扩到原来的10倍
新生代:老生代的比值为1:2
Edem:From:To的比值为8:1:1
目前这个方式应该还不是最优,因为JVM内存大小应该还可以继续扩大。