目录
- JVM 内存管理机制
- 虚拟机对象管理
- 垃圾收集策略与算法
- HotSpot 垃圾收集器
- 内存分配与回收策略
- JVM 性能调优
- 类文件结构
- 类加载的过程
- 类加载器
JVM内存管理机制
JVM运行时数据区
线程共享:方法区,堆
线程隔离:虚拟机栈,本地方法栈,程序计数器
- 方法区
- 作用:存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- 运行时常量池:用于存放编译器生成的各种字面量和符号引用
- 没有内存分配,抛OutOfMenmoryError
- 堆
- 作用:存放对象实例,几乎所有对象实例在这里分配内存
- 内存回收角度细分:新生代和老年代或者Eden空间、From Survivor空间、To Survivor空间
- 从内存分配角度划分:多个线程私有的分配缓冲区(TLAB)
- 没有内存分配,抛OutOfMenmoryError
- 虚拟机栈
- 生命周期和线程相同
虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(栈帧用于存储-> 局部变量表(编译期可知的各种基本数据类型、对象引用、returnAddress类型)、操作数栈、动态链接、方法出口等信息)每个方法的调用到执行完成对应栈帧的入栈出栈
- 如果线程请求的栈深度大于虚拟机所运行的深度,抛出StackOverflowError异常如果虚拟机栈可以动态扩展,扩展时无法申请足够的内存,抛出OutOfMemoryError
- 本地方法栈
作用和虚拟机栈相似,使用Native方法时服务
- 程序计数器
- 可以看作当前线程所执行的字节码的行号指示器。
- 分支、循环、跳转、异常处理,线程恢复等功能需要依赖计数器完成
- 如果线程执行Java方法,计数器记录正在执行的虚拟机字节码的指令地址,如果执行Native方法,这个计数器值为空(Undefined)
- 没有OOM
- 直接内存
直接内存不属于虚拟机运行时数据区的一部分,其大小自然不受java虚拟机堆限制,但会受到本机总内存大小限制。其大小可以通过-XX:MaxDirectMemorySize来设置,否则默认大小和堆大小一样大。回收只有等到虚拟机触发Full GC时才顺便对直接内存进行回收,或手动System.gc(),否则会报内存溢出异常。
虚拟机对象管理
- 对象的创建
- 虚拟机收到new指令,检查指令参数是否在常量池有该类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,则先执行类加载过程
- 类加载检查通过后,为新生对象分配内存,对象所需大小在类加载完成后便可完全确定
1.指针碰撞:堆内存绝对规整,所用过的内存放在一边,空闲的内存放在一边。中间放一个指针作为分界点的指示器,那所分配的内存仅需把指针向空闲空间那边移动与对象大小相等的距离(标记-清除算法)
2.空闲列表:栈内存不规则,虚拟机维护一个列表,记录哪些内存块可用,在分配时从列表中找到一块足够大的空间划分给对象实例(标记-整理算法)
3.内存分配线程不安全解决:一种是对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。- 虚拟机对分配到的内存空间初始化零值(不包括对象头),如果使用TLAB,这个过程可以提前至TLAB分配时进行
- 虚拟机对对象进行必要的设置(对象头),例如这个对象是哪个类的实例、如何找到类的元数据信息、哈希码、对象的GC分代年龄等信息。
- 从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象内存的布局
对象头:
1.存储自身的运行时数据(Mark Word):哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID、偏向时间戳等
2.类型指针,即对象指向它的类元数据的指针。通过这个指针确定是哪个类的实例,并不是所有的虚拟机实现。都必须在对象数据上保留类型指针。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。- 实例数据:
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。- 对齐填充
1.对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
2.HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。对象的访问定位
1.句柄:使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息
2.直接指针:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址
3.对比:使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
垃圾收集策略与算法
对象是否死亡?
- 引用计数算法
1.原理:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
2.优缺点:优点->效率高;缺点->相互循环引用
(对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器回收它们。)- 可达性分析算法
原理:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:虚拟机栈(栈帧中的本地变量表)中引用的对象。方法区中类静态属性引用的对象。方法区中常量引用的对象。本地方法栈中JNI(即一般说的Native方法)引用的对象。
对象的四种引用
- 强引用
强引用就是指在程序代码之中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。- 软引用
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
用于:实现缓存,如网页缓存、图片缓存等。- 弱引用
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
用于:在回调函数中防止内存泄漏- 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
垃圾收集算法
标记-清除算法
1.算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
2.主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法
1.将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
2.现在的商业虚拟机都采用这种收集算法来回收新生代,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
标记-整理算法
1.复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
2.根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
HotSpot 垃圾收集器
- 新生代
- Serial
- 只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程,即 Stop The World。一般客户端应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。因此 Serial 垃圾收集器适合客户端使用。
- 优点:由于 Serial 收集器只使用一条 GC 线程,避免了线程切换的开销,简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
- ParNew
- ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World。
- ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。
- 通过参数 -XX:+UseParNewGC 设置ParNew
- 通过参数 -XX:ParallelGCThreads 设置垃圾收集的线程数
- Parallel Scavebge
Parallel Scavenge 和 ParNew 一样,都是多线程、新生代垃圾收集器。但是两者有巨大的不同点:
- Parallel Scavenge:追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算。
- ParNew:追求降低用户停顿时间,适合交互式应用。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
追求高吞吐量,可以通过减少 GC 执行实际工作的时间,然而,仅仅偶尔运行 GC 意味着每当 GC 运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。单个 GC 需要花更多的时间来完成,从而导致更高的暂停时间。而考虑到低暂停时间,最好频繁运行 GC 以便更快速完成,反过来又导致吞吐量下降。- 通过参数 -XX:GCTimeRadio 设置垃圾回收时间占总 CPU 时间的百分比。
- 通过参数 -XX:MaxGCPauseMillis 设置垃圾处理过程最久停顿时间。
- 通过命令 -XX:+UseAdaptiveSizePolicy 开启自适应策略。我们只要设置好堆的大小和 MaxGCPauseMillis 或 GCTimeRadio,收集器会自动调整新生代的大小、Eden 和 Survivor 的比例、对象进入老年代的年龄,以最大程度上接近我们设置的 MaxGCPauseMillis 或 GCTimeRadio。
- 老年代
- CMS
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
- 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
- 并发标记:使用多条标记线程,与用户线程并发执行。此过程进行可达性分析,标记出所有废弃对象。速度很慢。
- 重新标记:Stop The World,使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来。
- 并发清除:只使用一条 GC 线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。
并发标记与并发清除过程耗时最长,且可以与用户线程一起工作,因此,总体上说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
- 优点:并发收集、低停顿
- 缺点:
CMS收集器对CPU资源非常敏感
并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低
CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生
1. CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃 圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
2. 在JDK 1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在 应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在JDK 1.6中,CMS收集器的启动阈值已经提升至92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
CMS是一款基于“标记—清除”算法实现的收集器,会有大量空间碎片产生
空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次FullGC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入FullGC时都进行碎片整理)。
- Serial Old(MSC)
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整
理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式
下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge
收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
- Parallel Old
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
- G1
- G1 是一款面向服务端应用的垃圾收集器,它没有新生代和老年代的概念,而是将堆划分为一块块独立的 Region。当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。
- 从整体上看, G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 这里抛个问题
一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?
并不!每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。- 如果不计算维护 Remembered Set 的操作,G1 收集器的工作过程分为以下几个步骤:
1 初始标记:Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。
2 并发标记:使用一条标记线程与用户线程并发执行。此过程进行可达性分析,速度很慢。
3 最终标记:Stop The World,使用多条标记线程并发执行。
4 筛选回收:回收废弃对象,此时也要 Stop The World,并使用多条筛选回收线程并发执行。- 对比CMS
对比CMS收集器运作过程,一定已经发现G1的前几个步骤的运作过程和CMS有很多相似之处。初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,从Sun公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。通过图3-11可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。- 特点:
1. 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
2. 分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
3. 空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
4. 可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
- 垃圾收集器参数
UseSerialGC:虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合
UseParNewGC:打开此开关后,使用ParNew+Serial Old收集器组合
UseConcMarkSweepGC:打开此开关后,使用ParNew+CMS+Serial Old收集器组合,Serial Old 作为CMS收集器出现Concurrent Mode Failure 失败后的后备收集器
UseParallelGC:虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge+Serial Old的收集器组合
UseParallelOldGC:打开此开关后,使用Parallel Scavenge+Parallel Old的收集器组合
SurvivorRatio:新生代Eden与Survivor区域比值,默认为8
PretenureSizeThreshold:直接晋升到老年代的对象大小
MaxTenuringThreshold:直接晋升到老年代的年龄
UseAdaptiveSizePolicy:动态调整Java堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure:是否允许担保失败
ParallelGCThreads:设置并行GC时进行内存回收的线程数
GCTimeRatio:GC时间占总时间的比率,默认值99,即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效
MaxGCPauseMillis:设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效
CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发垃圾收集。默认值 68%
UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否进行一次内存碎片整理
CMSFullGCsBeforeCompaction:设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。
内存分配与回收策略
分配策略
- 对象优先在Eden分配
- 大对象直接进入老年代:很长的字符串以及数组
- 长期存活的对象将进入老年代:参数-XX:MaxTenuringThreshold 设置
- 动态对象年龄判定: 如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
- 空间分配担保: 当出现大量对象在 MinorGC 后仍然存活的情况,就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。前提是老年代本身还有容纳这些对象的剩余空间。否则 Full GC
总结一下有哪些情况可能会触发 JVM 进行 Full GC。
System.gc()
方法的调用
此方法的调用是建议 JVM 进行 Full GC,注意这只是建议而非一定,但在很多情况下它会触发 Full GC,从而增加 Full GC 的频率。通常情况下我们只需要让虚拟机自己去管理内存即可,我们可以通过 -XX:+ DisableExplicitGC 来禁止调用System.gc()
。- 老年代空间不足
老年代空间不足会触发 Full GC 操作,若进行该操作后空间依然不足,则会抛出如下错误:java.lang.OutOfMemoryError: Java heap space
- 永久代空间不足
JVM 规范中运行时数据区域中的方法区,在 HotSpot 虚拟机中也称为永久代(Permanet Generation),存放一些类信息、常量、静态变量等数据,当系统要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,会触发 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出如下错误信息:java.lang.OutOfMemoryError: PermGen space
- CMS GC 时出现
promotion failed
和concurrent mode failure
promotion failed,就是上文所说的担保失败,而 concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的。- 统计得到的 Minor GC 晋升到旧生代的平均大小大于老年代的剩余空间。
- 堆中分配很大的对象
总结一下Full GC 次数太多了,如何优化
1) System.gc()的调用:可以设置 DisableExplicitGC 来禁止调用 System.gc 引发 Full GC
2) 老年代代空间不足:尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
3) 永生区空间不足:为避免 Perm Gen 占满造成 Full GC 现象,可采用的方法为增大 PermGen 空间或转为使用 CMS GC。
4) CMS GC 时出现 promotion failed 和 concurrent mode failure:promotion failed 是在进行 Minor GC 时,survivor space 放不下、对象只能放入老年代,而此时老年代也放不下造成的;concurrent mode failure 是在执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是 CMS GC 时当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC)。措施为:增大 survivor space、老年代空间或调低触发并发 GC 的比率
5) 统计得到的 Minor GC 晋升到旧生代的平均大小大于老年代的剩余空间:
JVM 性能调优
JVM 调优主要是针对内存管理方面的调优,包括控制各个代的大小,GC 策略。由于 GC开始垃圾回收时会挂起应用线程,严重影响了性能,调优的目的是为了尽量降低 GC 所导致
的应用线程暂停时间、 减少 Full GC 次数。
关键参数:-Xms -Xmx 、-Xmn 、-XX:SurvivorRatio、-XX:MaxTenuringThreshold、-XX:PermSize、-XX:MaxPermSize
(1)-Xms、 -Xmx 通常设置为相同的值,避免运行时要不断扩展 JVM 内存,这个值决定了 JVM heap 所能使用的最大内存。
(2)-Xmn 决定了新生代空间的大小,新生代 Eden、S0、S1 三个区域的比率可以通过-XX:SurvivorRatio 来控制(假如值为 4 表示:Eden:S0:S1 = 4:3:3 )
(3)-XX:MaxTenuringThreshold 控制对象在经过多少次 minor GC 之后进入老年代,此参数只有在 Serial 串行 GC 时有效。
(4)-XX:PermSize、-XX:MaxPermSize 用来控制方法区的大小,通常设置为相同的值。代调优
合理设置新生代大小
1)避免新生代大小设置过小
当新生代设置过小时,会产生两种比较明显的现象,一是 minor GC 次数频繁,二是可能导致 minor GC 对象直接进入老年代。当老年代内存不足时,会触发 Full GC。
2)避免新生代设置过大
新生代设置过大,会带来两个问题:一是老年代变小,可能导致 Full GC 频繁执行;二是 minor GC 执行回收的时间大幅度增加。年轻代大小选择
响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生的频率也是最小的。同时,减少到达年老代的对象。
吞吐量优先的应用:尽可能的设置大,可能到达 Gbit 的程度。因为对响应时间没有要求,垃圾收集可以并行进行,一般适合 8CPU 以上的应用。
年老代大小选择
响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:
- 并发垃圾收集信息
- 持久代并发收集次数
- 传统 GC 信息
- 花在年轻代和年老代回收上的时间比例
- 减少年轻代和年老代花费的时间,一般会提高应用的效率
吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代尽存放长期存活对象。
合理设置 Survivor 区
-XX:SurvivorRatio 参数的值越大,就意味着 Eden 区域变大,minor GC 次数会降低,但两块 Survivor 区域变小,如果超过 Survivor 区域内存大小的对象在 minor GC 后仍没被回收,则会直接进入老年代,
-XX:SurvivorRatio 参数值设置过小,就意味着 Eden 区域变小,minor GC 触发次数会增加,Survivor 区域变大,意味着可以存储更多在 minor GC 后任存活的对象,避免其进入老年代。合理设置对象在新生代的存活时间
新生代存活周期的值决定了新生代对象在经过多少次 Minor GC 后进入老年代。因此这个值要根据自己的应用来调优,Jvm 参数上这个值对应的为-XX:MaxTenuringThreshold,默认值为 15 次。初始堆大小和最大堆大小相同
-XX:PermSize、-XX:MaxPermSize 用来控制方法区的大小,通常设置为相同的值。避免运行时要不断扩展 JVM 内存,这个值决定了 JVM heap 所能使用的最大内存。较小堆引起的碎片问题
因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。如果出现“碎片”,可能需要进行如下配置:
-XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。
-XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次 Full GC后,对年老代进行压缩GC 策略调优
(1)合理选择垃圾收集器的搭配使用
(2)使用可视化工具 JConsole 查看 JVM 参数
JConsole 工具在 JDK/bin 目录下,启动 JConsole 后,将自动搜索本机运行的 jvm 进程,不需要 jps 命令来查询指定。双击其中一个 jvm 进程即可开始监控,也可使用“远程进程”来连接远程服务器。
类文件结构
JVM 的“无关性”
谈论 JVM 的无关性,主要有以下两个:
- 平台无关性:任何操作系统都能运行 Java 代码
- 语言无关性: JVM 能运行除 Java 以外的其他代码
Java 源代码首先需要使用 Javac 编译器编译成 .class 文件,然后由 JVM 执行 .class 文件,从而程序开始运行。
JVM 只认识 .class 文件,它不关心是何种语言生成了 .class 文件,只要 .class 文件符合 JVM 的规范就能运行。 目前已经有 JRuby、Jython、Scala 等语言能够在 JVM 上运行。它们有各自的语法规则,不过它们的编译器 都能将各自的源码编译成符合 JVM 规范的 .class 文件,从而能够借助 JVM 运行它们。
Java 语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的, 因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更加强大。 因此,有一些 Java 语言本身无法有效支持的语言特性,不代表字节码本身无法有效支持。
Class 文件结构
Class 文件是二进制文件,它的内容具有严格的规范,文件中没有任何空格,全都是连续的 0/1。Class 文件 中的所有内容被分为两种类型:无符号数、表。
- 无符号数 无符号数表示 Class 文件中的值,这些值没有任何类型,但有不同的长度。u1、u2、u4、u8 分别代表 > 1/2/4/8 字节的无符号数。
- 表 由多个无符号数或者其他表作为数据项构成的复合数据类型。
Class 文件具体由以下几个构成:
- 魔数
- 版本信息
- 常量池
- 访问标志
- 类索引、父类索引、接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
魔数
Class 文件的头 4 个字节称为魔数,用来表示这个 Class 文件的类型。
Class 文件的魔数是用 16 进制表示的“CAFE BABE”,是不是很具有浪漫色彩?
魔数相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在 Class 文件中标识文件类型比较合适。
版本信息
紧接着魔数的 4 个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪个版本的 JDK。
高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必需拒绝执行超过其版本号的 Class 文件。
常量池
版本信息之后就是常量池,常量池中存放两种类型的常量:
- 字面值常量
字面值常量就是我们在程序中定义的字符串、被 final 修饰的值。- 符号引用
符号引用就是我们定义的各种名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符。常量池的特点
- 常量池中常量数量不固定,因此常量池开头放置一个 u2 类型的无符号数,用来存储当前常量池的容量。
- 常量池的每一项常量都是一个表,表开始的第一位是一个 u1 类型的标志位(tag),代表当前这个常量属于哪种常量类型。
- 常量池中常量类型
类型 tag 描述 CONSTANT_utf8_info 1 UTF-8 编码的字符串 CONSTANT_Integer_info 3 整型字面量 CONSTANT_Float_info 4 浮点型字面量 CONSTANT_Long_info 5 长整型字面量 CONSTANT_Double_info 6 双精度浮点型字面量 CONSTANT_Class_info 7 类或接口的符号引用 CONSTANT_String_info 8 字符串类型字面量 CONSTANT_Fieldref_info 9 字段的符号引用 CONSTANT_Methodref_info 10 类中方法的符号引用 CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用 CONSTANT_NameAndType_info 12 字段或方法的符号引用 CONSTANT_MethodHandle_info 15 表示方法句柄 CONSTANT_MethodType_info 16 标识方法类型 CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点 对于 CONSTANT_Class_info(此类型的常量代表一个类或者接口的符号引用),它的二维表结构如下:
类型 名称 数量 u1 tag 1 u2 name_index 1 tag 是标志位,用于区分常量类型;name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量代表这个类(或接口)的全限定名,这里 name_index 值若为 0x0002,也即是指向了常量池中的第二项常量。
CONSTANT_Utf8_info 型常量的结构如下:
类型 名称 数量 u1 tag 1 u2 length 1 u1 bytes length
tag 是当前常量的类型;length 表示这个字符串的长度;bytes 是这个字符串的内容(采用缩略的 UTF8 编码)
访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否被 abstract/final 修饰。
类索引、父类索引、接口索引集合
类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。
由于 Java 不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。一个类可能实现了多个接口,因此用接口索引集合来描述。这个集合第一项为 u2 类型的数据,表示索引表的容量,接下来就是接口的名字索引。
类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过该常量总的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。
字段表集合
字段表集合存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。
每一个字段表只表示一个成员变量,本类中的所有成员变量构成了字段表集合。字段表结构如下:
类型 名称 数量 说明 u2 access_flags 1 字段的访问标志,与类稍有不同 u2 name_index 1 字段名字的索引 u2 descriptor_index 1 描述符,用于描述字段的数据类型。 基本数据类型用大写字母表示; 对象类型用“L 对象类型的全限定名”表示。 u2 attributes_count 1 属性表集合的长度 u2 attributes attributes_count 属性表集合,用于存放属性的额外信息,如属性的值。 字段表集合中不会出现从父类(或接口)中继承而来的字段,但有可能出现原本 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
方法表集合
方法表结构与属性表类似。
volatile 关键字 和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT 标志。
方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令。
属性表集合
每个属性对应一张属性表,属性表的结构如下:
类型 名称 数量 u2 attribute_name_index 1 u4 attribute_length 1 u1 info attribute_length
类文件加载机制
双亲委派机制
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。好处:
确保安全,避免Java核心类库被修改
避免重复加载;
保证类的唯一性打破双亲委派(思考:为什么还需要打破?)
自定义类加载器,重写loadClass方法- 双亲委派源码分析
类文件加载器
类加载过程
类加载过程包括 5 个阶段:加载、验证、准备、解析和初始化。
加载
- 通过一个类的全限定名(classpath、jar、网络、硬盘)来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
链接
验证
- 作用:验证阶段确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
- 过程:
- 文件格式验证 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,验证点如下:
- 是否以魔数 0XCAFEBABE 开头。
- 主次版本号是否在当前虚拟机处理范围内。
- 常量池是否有不被支持的常量类型。
- 指向常量的索引值是否指向了不存在的常量。
- CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 编码的数据。
- ......
- 元数据验证 对字节码描述信息进行语义分析,确保其符合 Java 语法规范。
- 字节码验证 本阶段是验证过程中最复杂的一个阶段,是对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。
- 符号引用验证 本阶段发生在解析阶段,确保解析正常执行。
准备
准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。
初始值“通常情况下”是数据类型的零值(0, null...),假设一个类变量的定义为:
public static int value = 123;
那么变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法。
存在“特殊情况”:如果类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段 value 就会被初始化为 ConstantValue 属性所指定的值,假设上面类变量 value 的定义变为:
public static final int value = 123;
那么在准备阶段虚拟机会根据 ConstantValue 的设置将 value 赋值为 123。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。初始化
- 初始化阶段是执行类构造器
()方法的过程。类构造器 ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的。 - 当初始化一个类的时候,如果发现其父类还没有初始化,则需要先初始化其父类。
- 虚拟机会保证一个类的
()方法在多线程环境中被正确的加锁和同步。(线程安全) - 当访问一个java类的静态域时,只有真正声明这个域的类才会被初始化。
类加载器
定义:在加载阶段,通过类的全限定名来获取描述该类的二进制节流的这个动作的"代码"被称为"类加载器"
启动类记载器(BootstrapClassLoader)
这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。
扩展类加载器(ExtClassLoader)
这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类记载器
这个类加载器由sun.misc.Launcher $AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
自定义类记载器
用户可以自己定义类加载器来加载类。所有的类加载器都要继承 java.lang.ClassLoader 类并重写 findClass(String name) 方法。用户自定义类加载器默认父加载器是 应用程序加载器。