认识JVM(上)——对象分配&回收算法

本来标题党想写成《深入JVM》,不过不太敢写,我想一小篇博客我想还不足以说明JVM,在本文中,会就我所知给大家介绍JVM的很多内部知识,概念会相对较粗,因为太细的内容要写,这里肯定写不出来;本文主要偏重理论,没有什么实践,中间除一些官方资料外,还有部分自身的理解,所以请大家不要完全信任本文内容;另外本文会有一小部分纠正以前一篇文章对于intern()使用方法的错误,本文会在其中说明使用错误的原因,大致文章内容有以下几个部分:

1、JVM虚拟内存组成及操作系统地址表

2、新生成对象在HeapSize是如何变化的

3、虚拟机如何定义回收算法

4、JVM占用的空间除HeapSize还会占用什么,OutOfMemory种类!

5、纠正错误:intern()的使用上的错误

好,现在开始话题吧:

1、JVM虚拟内存组成及操作系统地址表

1.1.虚拟地址大致概念

在OS层面一般是由逻辑地址映射到线性地址,如果线性地址管理,如果启动了分页,那么线性地址就会转换到相应的物理地址上,否则就直接认为是物理地址;程序设计中所用到的地址单元就是逻辑单元,如在C语言中的&表示指定的地址就是逻辑地址;而物理地址也并非我们所认为的RAM,还应该包括网卡、显存、SWAP等相关内容,也就是由OS所管理所有可以通过顶层逻辑单元映射到的目标地点,不过绝大部分情况下只需要考虑RAM即可,尤其是在服务器上;JVM的虚拟内存地址和操作系统的虚拟内存地址不是一个概念,操作系统的虚拟内存地址相当于在磁盘上划分的一个SWAP交换区,用于内存,内存与之做page out和page in的操作,这种用于物理内存本身不够,而地址空间够用的情况,一旦程序出现page out这些情况的时候,程序将会变得非常缓慢,而JVM的虚拟内存是在有效的空间内分配一个连续的线性地址空间,因为JVM想要自己管理内存,分配的堆内存都是在自己的heapSize内部,因为它要实现一些脱离于存储器本身对非连续堆处理的管理而导致的复杂性,也就是JVM去初始化的时候就会加载一块很大的内存单元,然后内部的操作都是内部自己完成的。

1.2.内存分配

一般C语言分配内存是初始化将相应的基本内容和代码段进行加载,但是不会加载运行时候的堆栈内存分配,也就是在运行到某个具体的函数时通过malloc、callloc、realloc等方方申请的区域,这些区域必须从操作系统中重新来分配,使用完成后必须进行free,C++中必须使用delete方法来释放,大家发现没有,OS的堆在内存不断申请和释放的过程中,必然会产生许多的内存碎片,从而导致你在申请一块大内存的时候,需要进行逻辑连接,导致在申请的速度减小,当然LINUX采用了将内存块划分为多个不同大小的板块,来较好的处理这个问题,不过片段还是存在的,不过这种思想的确是很好的,而JVM是如何完成碎片的处理的呢,后面章节会说到;JVM在初始化的时候就会向OS申请一块大内存,JVM要求这块内存在地址空间上是连续的(物理上未必连续),让所有的程序在这个内部区分配,由自己来管理,所以它内部相当于做了一个小的OS对内存的管理,所以JVM是想让java程序员不用关心在哪一个平台上写代码,但是你一定要关心java怎么管理内存的;

线性地址随着实际物理内存的增加,将会导致页表非常大,甚至于导致多层页表,如内存达到96G这一类,那么这样管理起来将会非常麻烦(正常情况下一个页只有4K,可以自己算一下需要多少个管理地址来指向这个4K,这个管理地址太大的时候,又需要其他的管理地址来管理这个地址,就会导致多层地址,可能到最后,一个大内存有40%都是用于管理内存的,真正使用的可想而知),所以在LINUX高版本中对于内存寻址方面做了改进,就是支持大页面来支持(其实是通过一个套件完成的,并非OS本身),如一个页的大小为1M这样的,但是有一些风险在里面,它要求大页面内存要么放得下你的内存,但是你不能将你的进程一部分放在大页面内存中,一部分放在OS管理的小页面内存中,也就是说要么这块放得下,要么就放在其他地方,可能会导致两边正好都差那么一点点的问题,在OS这边可以使用SWAP,但是系统会很慢,而且SWAP很多的情况下肯定会宕机掉。

1.3.内存分配状态

一个大的进程如果初始化需要分配一块大的内存空间,内存空间一般会经历两个状态的转换过程,首先内存必须是free状态才可以被分配,如果的确是该状态并且空间是够用的,那么它首先会占用那么大一个坑,在java的heapSize中,就是-Xmx参数指定的,也就是JVM虚拟机最大的内存空间(注意这里-Xmx并没有包含PermSize的空间),这个坑是不允许其它进程所占用的,内存的状态为:reserved的状态,当需要使用的空间时,内存将会被commited状态,在JVM初始化时也就是-Xms状态的内存空间,处于这个状态的内存如果发现不够使用(物理内存),此时就会发生swap区域,程序将会变得非常缓慢,但是不会造成宕机,而很多时候在这个时候定位不出原因,所以我们为了让物理内存不够用的现象暴露出来可以被发现,至于可以定位不是程序代码的问题,我们就直接将swap内存禁用掉;有个问题就是既然被reserved的内存就不能被其他进程所占用,为什么要在这两个状态之间来回倒腾呢?这不是多一个开销吗?JVM在来回倒腾的过程中会导致每个区域的容量发生相应的变化,必然导致的是FullGC的过程,那么JVM一般在服务器端如何设置呢?文章后面逐步细化说明。

1.4.JVM内存组织

关于JVM内存组织方面,前面在讲述Java垃圾回收的时候已经提及到了,但是讲得不太细,有些部分可能算是有错误的,所以这里根据上述操作系统知识以及官方部分资料继续深入,不敢说完全正确,不过至少比以前要更加深入得多,首先来看下ORACLE官方给出来的一个JVM内存单元的组织图形:

JVM内存

其实我看过很多次这个图看得很晕,因为以前不了内存分配中commited与reserved的区别,以至于我当时认为这副图是说java的HeapSize是由N多个部分组成的,并且还包含HeapSize的,其实在经过很多资料查阅后,尤其是看到一些监控工具后,才知道看官方资料也有误区,呵呵,通过简化,我自己画的这副图希望能够帮助大家理解JVM的大致的内存划分(这里仅仅提及JVM自己的内存,也就是HeapSize和PermSize的部分,其余的文章后面说明),这里仅仅将上面的图形立起来画了,当时看起来要方便理解得很多(个人感觉):

也就是说,你首先需要将JVM的两个大板块分开,一个是HeapSize,也就是上图左侧的部分,右边部分为PermSize的尺寸,HeapSize也划分为大区域为Young和Old区域,Young区域内部划分为三个部分,一个是Eden和两个同样尺寸大小的survivor区域,注意到的人会发现为什么每个区域内部还有一个virtual区域,这就是我们上面说的没有经过commited当时已经占用了地址列表,它不能被其他进程所占用,当时操作系统一般的提示会认为这是块剩余空间,但是实际上是只能被自己使用的,这部分上面已经提及,至于为什么我们后面来解释,这里再提出一些问题,就是为什么JVM要提出这么多区域划分来管理呢?如果一个区域可以管理为什么还要搞得那么麻烦呢?这么多区域有什么用处,我们在第二章对象的分配中将详细说明这部分内容。

2、新生成对象在HeapSize是如何变化的

2.1.java新创建对象的方法有哪些

首先学习过java的人可能没有人不知道new 这个关键字,也就是新创建一个对象的关键字,当发生new操作时,jvm为你做了什么?我们先把这个问题放下,对于jvm初始化加载专门处理,这里先说除了new之外还有什么方式,就是通过java.lang.Class.forName进行动态状态后,获取一个新的实例,当然方法有重载,也通过通过ClassLoader进行动态状态,什么是动态装载?为什么有了new还要有动态装载?而jvm初始化做了什么?动态装载和new的区别是什么?这也是我们下面要讨论的问题,也是PermSize中内容的一大块部分。

2.2.jvm初始化需要做什么

Jvm在向OS请求了一块地址列表后,然后就需要初始化了,初始化要做什么呢?jvm启动相当于一个进程,当然它可以再启动子进程,这里我我们只考虑单个进程,进程启动必然需要初始化一些内容,C语言或者C++它会将相应的全局变量以及代码段等内容在内存中进行编译为相应的指令集;而jvm做了什么呢?jvm它也需要做一些操作;首先每一个进程都必须最少一个引导进程,也就是我们说的main,通过引导进程所关联,以及关联的关联(也就是import),jvm会将这些关联关系的内容形成一个大的jvm网状结构用于关系于class之间并保证每一个class有一份自己的私有池,他们放在哪里,他们就是放在PermSize,也就是很多中文翻译中的永久代,每一个Class都有自己独立的私有池去管理自身的结构,对一个java程序源文件,编写的是对于程序的描述信息,生成class也就是描述信息的byte格式(在这个过程中会自动完成一些简单逻辑合并工作),byte格式是字节码格式,也就是按照每8个bit位组成的计算机基本格式,只要字符集统一,则为每一个操作系统所认知的格式,JVM需要做的是将这些统一认知的格式信息翻译为对应操作系统的指令或硬件指令,所以JVM真正的意义就是为每一个操作系统编写了一个统一的JRE,即:java运行时环境,而编译环境是所有系统都可以使用的;初始化将class的定义加载到内存中会进行相应的转换和压缩,总之会形成原有对类型描述和执行顺序,而不会出现混乱,但并不是对应的操作系统指令(对应的操作系统指令是运行时知道的),如描述类型、作用域、访问权限等等内容,这部分空间大小决定于class的多少,也就是你的工程的大小,PermSize还包含了其他的内容,并且只是在一般情况下不会发生GC,但是有些时候还是会发生GC的,在后面继续说明;这个加载完成后,他们在池中自然有自己的内存首地址,要寻找他必然要有对应列表,列表的基础肯定是属于符号向量了,也就是基于名称的一个符号向量,那么当发生new时,它会在符号向量中寻找对应的class,找到后将符号地址转换为对应的class地址,并且这个内容只会被转载一次,以后可以直接被利用,从中找到了class的定义,在堆中分配内存时将其定义部分的某些组织单元放置与对象的头部,这些代码段对于对象来说是彼此独立,就像你在方法体前面增加synchronize关键字,对于非静态方法来说,不同的对象这个关键字是相互不会影响的,也就是说,如果多个线程调用的对象不是同一个,仅仅在方法(非静态方法)体上面增加synchronized这对于多线程同步是无效的(更多关于多线程的知识,如关锁方面的Lock、Atomic等方面的知识不是本文的内容,这里不再展开讨论);注意,这里还没有谈到申请对象以及动态装载,动态装载的class一般是不会JVM初始化的时候转入Perm的,而是运行时动态装载进去的,就像JDBC驱动一样,大家几乎都用动态装载来实现动态加载不同数据库连接的目的;也就是我们上一节提出的问题,动态装载做什么?它负责的是运行时装载一些类的定义,而不是初始化,当然,当你通过全名去加载的时候,他们会从符号向量中寻找这个类是否已经加载,如果已经加载则直接使用,否则从相应的包中获取这个class定义,然后装载起来,装载的单位也是以class为单位,并不是以jar包为单位,这里请大家如果不要滥用动态加载,一个是造成Perm的不稳定,另一个是它的效率肯定没有new高,因为它需要先去通过符号向量寻找是否存在,不存在再加载,然后再通过newInstance实例化一个或多个实例,当然在某些特殊的时候,利用它可以为你的程序带来极高的灵活性。

2.3.内存申请时的指针与实例

内存申请时上一节已经说到地址空间的和符号引用得到对应数据结构的方法,这里不再提及,这里就将对象作为整体,在堆中;在JVM的初衷中,它希望新申请的内存是连续的,虽然堆的定义是让内存是随机分配的,但是对于整个JVM来说,它希望分配的内存是较为连续的,也就是按照较为条带化的方式进行分配,好处有好几个,一个是这样非常的简单,经过精简后的情况目前一个new翻译为机器码只需要10条左右的指令码,近乎与C语言,所以在高版本的jdk中,new的开销不再是java虚拟机慢的一个原因,大家也没有必要去尽量减少new,但是也不要滥用,业绩虽乱定义不必要的对象;其次,另一个好处,当内存较为连续后,内存在分配上就没有类似的大量碎片的问题,造成运行一段时间后,大量碎片,当需要申请一个大内存的时候,需要寻找非常多的地方才能将其逻辑上组成,而导致分配空间上不必要的浪费;而一个简单内存分配String a = new String(“abc”);,这样一条代码,会做什么动作呢?a相当于是对象的一个指针一样的东西,这个空间的大小为一个long的长度,也就是可以支持到可以想象的任何内存大小,它并不是存放在heapSize中的,而是放在stack中的,由OS来调度管理,也就是当a的作用区域完成,这个指针将会断开,java中的String不再是C或者C++中的一个指针指向的一个字符数组,而是一个被包装后的对象,也就是java为什么说自己都是对象,因为它把原生态的内容进行了包装,让程序编写更加简单;这里顺便提及一下:在较早期的jdk中,jvm并不是由一个指针直接指向分配堆中的首地址,而是先有一个handle空间,这个空间存放了开始说的一些对象的定义和结构信息,也就是找到该位置,然后由该位置转换到对应的对象上,但是那个时候的对象头部信息就没有现在的那么全,也就是以前是将一部分handle内容放置在独立的空间上,现在的jdk已经没有那样的了。

2.4.内存分配后放在哪里,如何移动

终于回到上面的话题,内存分配后,在堆中的什么位置?就是我们上面说的heapSize中的Young区域的Eden区域中,也就是new的对象绝大部分会放在这里(排除一种非常大的对象的特殊情况),在java设计的看来有一个特别有意思的地方,就是它在新生成的对象中它认为你绝大部分对象都是应该需要被销毁掉的,就像在做java WEB应用上一样,一个列表请求过来,可能请求的内容有2K的内容,请求完成后,这个内容一般说来自然就不需要了,也就是在他原始的考虑下它没有考虑你自己在应用级别去做page cache的操作;好,那么当内存不够的时候,这里指被commited的空间不够的情况下,此时java就会做一个动作,就是会对Young空间进行回收,由于新生成的对象,java认为这块空间不会很大,而且绝大部分应该是被干掉的内容,所以很多时候java会采用单线程的复制算法(当然你也可以设置为多线程),关于算法的核心在第三章中会说到,这里总之先理解找到了活着的对象,将其拷贝到其中一个survivor区域中,当下一次做操作时,就会将Eden中活着的以及前一个surivor活着的一起拷贝到另一个survivor中,这就是为什么要设置两个survivor区域,而拷贝后,Eden区域为空、另一个survivor也为空,可以完全直接整体清除掉,所以非常快速,而拷贝的目标也会被连续化,新生成的对象又从Eden的初始位置开始分配空间。

当对象每次(活着)被拷贝到一个survivor时,Java虚拟机就会记录下来对象被移动的次数,当次数达到一定的程度,也就是官方文档所说的足够老的情况,这块内存就认为它不太容易被注销掉,此时就会被移动到第二个区域Tenured区域,这个次数也可以由自己来控制。

另外在一般默认的情况下当回收后的内存仍然占用实际目前commited内存的70%以上,那么此时虚拟机将会开始扩展这些内存,而当回收后的内存小于40%后,虚拟机将会降低这部分内存,但是其他线程仍然不能使用(当然这个参数也是可配置的,在文章最后有说明),这样收缩和扩展必然导致一些问题,但是java的初衷是想让你再没有使用这块地址表的时候,回收内存的大小会小一些,因为young区域的一般是使用单线程的回收方式,这个时间段是会被暂停的,所以它认为内存使用较少的时候回收就内存的速度应该加快;但是,和实际相反的是,我们正好需要的是内存使用较大的时候,才希望加快回收的速度,内存使用小的时候,回收都是无所谓的;所以我们在很多时候建议将-Xms和-Xmx设置成一样的大小,不用这么来回倒腾。

在说明下,以下三种情况对象会被晋升到old区域:

1、在eden和survivor中可以来回被minor gc多次,这个次数超过了-XX:MaxTenuringThreshold

2、在发生minor gc时,发现to survivor无法放下这些对象,就会进入old。

3、在新申请对象,大于eden区域的一半大小时直接进入old,也可以专门设置参数-XX:PretenureSizeThreshold这个参数指定当超过这个值就直接进入old。

当上面的对象被移动到了Tenured区域,这个区域一般非常大,占用了HeapSize的绝大部分空间,此时若它发生一次内存回收,就不能像刚才那样来回拷贝了,那样代价太大,而且这个区域可以说是经得起考验的对象才会被移动过来,在概率上是不容易被销毁掉的对象才会被移动过来;那么,我们很此时想到的就是反过来计算,也就是找到需要销毁的对象,将其销毁,关于算法也是下面第三章要说的内容,总之对象会在这里存放着。

为什么java不论在Young中的区域会来回倒腾,而在Tenured区域也会不断去做压缩,就是我们前面说的,它希望内存相对较为连续而做的;java在Yong的区域,它认为可以剩下的内容不会很多,所以拷贝的代价并不大,所以它认为来回拷贝是一种合适的方法,而Tenured区域它采用了清除后,一定次数后进行压缩的方式,当然这个次数你可以自己去设置,在文章的最后是有参数的;而它没有采用类似操作系统一样的按照板块大小等一系列算法来完成,这也是我比较纳闷的事情,不过总体说来这种算法还是可行的;希望在划分区域一些策略上能有更大的灵活性,这样可以在更多的应用中发挥得更加灵活,这样就更好了;比较困惑的就是这样的架构自己如果做频繁度不高不低的page cache,性能不好估量,也许比不做cache更低,这个要根据具体情况而定了。

2.5.Perm一般还会存放什么内容

Perm除了存放上面的Class定义外,还一般会存放的内容有静态代码段、final static类型的类变量、String常量以及String被intern后的内容,也是最后一章中所要提及以前我自己写错的内容;如何应对好常量池,以及常量池是否会被GC,也是我们所需要说明的内容;关于Perm永久代中存放的内容,应当如何配置以至于它可以去回收,在文章的最后有相应的说明,请自行查阅;不过对于Perm的大小,一般还是不建议去做GC的,也就是合理的去使用Perm,在程序运行中占用Perm最多的就是String常量,尤其是如果大量使用intern的时候,就会造成大量Perm膨胀,也是最后一部分需要说明的内容,不过intern也并非一无是处,因为你可以这样说:如果它没有用处的话,java没有必要再把String的常量放在单独的一个地方,它有很多好处,只要在适当的时候利用好常量池这个区域在必要的时候可以提高性能,具体在最后一章有所讲解。

3、虚拟机如何定义回收算法

3.1.首先虚拟的回收算法会分成两个部分

一个部分是对象的查找算法,一个是真正如何回收的方法。一般对于查找有以下两种:

a)引用计数:本来在本文中我不想提及引用计数,因为这是最原始也是最垃圾的算法,也是较低版本jdk慢得出奇的原因,但是为了说明后面的问题不得不简单说明一下,引用计数就是通过java虚拟机专门为每个对象记录它被指针指向的个数,当发生指针指向它或者被赋值,计数器将会被加1,而但指向它的指针=null或者脱离了作用区域,jvm就会将相应的计数器减少1,这样简单,但是慢死了,不仅仅操作上出奇的慢,因为要做一个简单的赋值操作要到多个地方去找一大堆东西;还有一个就会引起很难检测到的内存泄露,那就是当两个或者多个对象存在循环交叉引用的时候,此时他们的引用计数将永远不会等于0(如使用双向链表或使用复杂的集合类后,相互之间的引用),也就是垃圾收集器将永远不会认为这是垃圾(当然要用复杂的算法可以解决,但是这个算法的确很复杂,可能垃圾回收会更加慢),最后就是这个垃圾回收方式必然导致内存的遍历操作过程。引用计数的示意图如下图所示:

b)引用树遍历:其实是一个图,只是有根而已,它沿着对象的根句柄向下查找到活着的节点,并标记下来,其余没有被标记的节点就是死掉的节点,这些对象就是可以被回收的,或者说活着的节点就是可以被拷贝走的,具体要看所在heapSize中的区域以及算法,它的大致示意图如下图所示(对象:B、G、D、J、K、L、F都是垃圾对象,虽然他们也有相互指向,但是不是被根节点能遍历到的,注意这里是指针是单向的):

3.2.内存回收

上面的方法我们可以找到内存可以被使用的,或者说那些内存是可以回收,更多的时候我们肯定愿意做更少的事情达到同样的目的,我们会根据一般的情况设置不同的算法来让系统的性能达到较好的程度,首先来了解下内存回收的算法或者它的经历有哪些?

a):标记清除算法,这算是比较原始的算法,也就是通过上面的查找标记后,我们对没有标记的对象进行空间释放的过程,这个算法虽然很原始,但是是后来所有算法的基础,好处的简单,缺陷是造成和其他语言一样的内存碎片,要通过更加复杂的算法来解决这些碎片;另一缺陷就是它这个过程如果用于较大的内存将会导致长时间的对外服务停止(当然这个停止也不是传说中那么长,只是相对计算机来说比较长,至于多长是还和jdk的版本以及厂商有关系,BEA曾经在1G的JVM下面测试,有300M空间属于可用空间,据测试结果为30ms的停止服务时间,我想这个时间应该可以接受,不过它有自己的测试场景,不能完全说明问题,而一般情况下在单线程引用下,常规的回收起码会比这个时间要长好几倍甚至于10倍以上)。

b):标记清楚压缩,这个算法是也是较为原始的,它的出现是为了解决上面一种算法中不能压缩空间的问题,但是并非取代,因为它导致的另一个问题就是更长时间的服务停止,因为压缩就是空间拷贝到一个较为连续的地方,而并非对数据本身进行压缩,所以很多时候他们是配合使用的,如多少次清除后进行一次压缩。

c)复制回收:也就是在jvm发展的过程中出现的算法,现在基本都只能看到一些思想影子在里面,但是没有这个方式,也就是将其划分为2个相同的大小,然后将活着的节点来回拷贝,这样造成的内存浪费的非常大的,不仅仅是一半的浪费问题,而且每次拷贝的开销也是非常大的,因为都是涉及到整个jvm活着节点的拷贝过程。

 d)增量回收:这算是现代垃圾回收的一个前身,它做的事情就是为了解决复制回收算法中的一个问题,就是每次复制造成的空间开销非常大的问题,此时它将内存中切分为逐个板块,这些板块,每个内部使用了复制算法,也就是并没有解决空间浪费的问题,回收的过程中没有进行细化,虽然回收速度较快速,而且只会造成局部的停止服务,但是对于不同板块大小、不同生命周期的对象还是没有划分开。

e)分代收集器:分代收集器是增量收集的另一个化身,或者说延续吧,它将板块按照生命周期划分为上面所说的板块,每一个板块可以采用不同的算法进行回收,这也是和增量回收最大的区别,此时可以让jvm的回收达到更好的效果,不过由于jvm按照生命周期划分后都是指定板块的,所以根据内存大小划分自定义板块是不可能的,至少现在好像还没有,所以在回收过程中如果内存大了回收起来一样很吃力,尤其是对Old区域的回收,所以并发回收不得不出现了。

f)并发回收:所谓并发回收是指外部在访问的同时,java回收器依然在做着回收工作,原早我认为并发回收是不可能的,因为你需要知道内存是需要回收的,就不能让内存继续的被申请和释放,但是SUN的人还是比较天才的,还是有办法尽量让他并发去做的;并发回收器其实也会暂停,但是时间非常短,它并不会在从开始回收寻找、标记、清楚、压缩或拷贝等方式过程完全暂停服务,它发现有几个时间比较长,一个就是标记,因为这个回收一般面对的是老年代,这个区域一般很大,而一般来说绝大部分对象应该是活着的,所以标记时间很长,还有一个时间是压缩,但是压缩并不一定非要每一次做完GC都去压缩的,而拷贝呢一般不会用在老年代,所以暂时不考虑;所以他们想出来的办法就是:第一次短暂停机是将所有对象的根指针找到,这个非常容易找到,而且非常快速,找到后,此时GC开始从这些根节点标记活着的节点(这里可以采用并行),然后待标记完成后,此时可能有新的 内存申请以及被抛弃(java本身没有内存释放这一概念),此时JVM会记录下这个过程中的增量信息,而对于老年代来说,必须要经过多次在survivor倒腾后才会进入老年代,所以它在这段时间增量一般来说会非常少,而且它被释放的概率前面也说并不大(JVM如果不是完全做Cache,自己做pageCache而且发生概率不大不小的pageout和pagein是不适合的);JVM根据这些增量信息快速标记出内部的节点,也是非常快速的,就可以开始回收了,由于需要杀掉的节点并不多,所以这个过程也非常快,压缩在一定时间后会专门做一次操作,有关暂停时间在Hotspot版本,也就是SUN的jdk中都是可以配置的,当在指定时间范围内无法回收时,JVM将会对相应尺寸进行调整,如果你不想让它调整,在设置各个区域的大小时,就使用定量,而不要使用比例来控制;当采用并发回收算法的时候,一般对于老年代区域,不会等待内存小于10%左右的时候才会发起回收,因为并发回收是允许在回收的时候被分配,那样就有可能来不及了,所以并发回收的时候,JVM可能会在68%左右的时候就开始启动对老年代GC了。

g)并行回收:并行回收指利用多个CPU对JVM进行并行垃圾回收的过程,并行度都是可以设置的,可以分别对年轻代和老年代配置是否使用并行回收。

好了,回收算法就说到这里,那么如何利用好回收算法,在看了上面的介绍后,是否对JVM有了一个大致的了解,具体细节,可以慢慢实践,在文章最后给出一些常用的java虚拟机内存设置参数的说明,不过并不权威,需要根据实际情况而定才可以。

下面说下java虚拟机除了消耗基本内存外还会消耗什么内存?

4、JVM占用的空间除HeapSize还会占用什么

一般来说,对于很多学了好几年,甚至于很多年java人来说,一旦看到OutOfMemeory(简称OOM),就认为HeapSize不够,然后疯狂的增加-Xmx的值,但是HeapSize只是其中一个部分,当你去做一个实验,也就是java启动时直接在程序中疯狂的new 一些线程出来,直到内存溢出,当-Xms -Xmx设置得越大的时候,得到的线程个数会越少,为什么呢?因为OOM并不是HeapSize不够而导致的,而由很多种情况。

首先看下操作系统如何划分内存给应用系统,其实在Win 32、Linux 32的系统中,地址总线为32位的理论上应该可以支持4G内存空间,但是当你在Win 32上设置初始化内存如果达到2G,就会报错,说这个块空间没法做,首先默认的Win32系统,会按照50%比例给予给Kernel使用,而另一部分给应用内存,也就是说操作系统内核部分不论是否使用,这一半是不会给你的,而还有2G呢,它在系统扩展的部分,也就是并非Kernel的部分,有很多静态区域和字典表的内容,所以要划分一个连续的2G内存给JVM在Win 32上是不可能的,Win 32提出了一种Win 32 3G模式,貌似可以划分3G空间,其实它只是将内核部分缩小也就是管理部分缩小,也就是将一部分划分到外部来使用,而且Win 32习惯在内存2G的位置做一些手脚,让你分配连续2G没有可能性,一般来说在Win 32平台上,在物理内存足够的情况下给JVM划分的空间一般是1.4~1.5G左右,具体数据没有测试过;而Linux 32类似于Win 32 3G模式,但是它还是一般情况下分布不凌乱的情况下,一般可以给JVM划分到2G的大小。Linux 32 Hugemem是一个扩展版本,可以划分更大的空间,但是需要付出一些其他的代价,理论上可以支持到4G给应用,也就是Kenel是独立的;Solaris x86-32和AIX 32等系统,也类似于Linux 32平台一样。

为什么还要预留一些空间出来呢?这些空间给谁?

当你申请一个线程的时候,它的除了线程内部对象的开销外,线程本身的开销,是需要OS来调度完成,一般来说,会在OS的线程与虚拟机内部有都有一个一一对应的,但是会根据操作系统不同有所变化,有些可能只有一个,总之heapSize外的那部分空间是跑不掉的,它放在哪里呢?就是放在Stack中的,所以上文中的-Xss就是设置这个的,在jdk 1.5以后,每个线程的大小被默认设置为1M的stack开销,我们习惯将这个开销降低。

好了知道了指针、线程是在heapSize外部的,还有什么呢?

当你自己使用native方法,也就是JNI的时候,调用本地其他语言,如C、C++在程序中使用了malloc等类似方法开辟的内存,都不是在heapSize中的,而是在本地OS所掌控的,另外这部分空间如果没有相应的释放命令,就需要在对应finalize方法内部调用其他的native方法来完成对相应对象的释放,否则这部分将成为OS级别的内存泄露,直到JVM进程重启或者宕机为止(操作系统会记录下进程和相应线程和堆内存的关联关系,但是进程再没有释放前,OS也是不会回收这部分内存的)。

另外在使用JavaNIO以及JDBC、流等系列操作时,当形成与终端交互时,会在另一个位置形成一个内存区域,这些内存区域都不在HeapSize中。

所以常见的OOM现象有以下几种:

1、heapSize溢出,这个需要设置Java虚拟机的内存情况

2、PermSize溢出,需要设置Perm相关参数以及检查内存中的常量情况。

3、OS地址空间不够,也就是没有那么多内存分配,这个一般是启动时报错。

4、Swap空间频繁交互,进程直接被crash掉,在不同操作系统中会体现不同的情况。

5、native Thread溢出,注意线程Stack的大小,以及本身操作系统的限制。

6、DirectByteBuffer溢出,这一类一般是在做一些NIO操作的时候,或在某种情况下使用ByteBuffer,在分配内存时使用了allocateDirect以及使用一些框架间接调用了类似方法,导致直接内存的分配(如mina中使用IoByte去调用,当参数设置为true的时候就分配为直接内存,所谓直接内存就是又OS定义的内存,而不需要从程序间接拷贝一次再输出的过程,提高性能,但是如果没有手动回收是回收不掉的),导致的Buffer问题,如输出大量的内容,输入大量的内容,此时需要尽量去尝试限制它的大小。

使用非常多的工具区检测Java的内存如:jstat(只能看HeapSize和PermSize)、jmap(很细的东西)、jps(java的ps -ef呵呵)、jdb(这个不是监控工具哈,这个是debug工具)、jprofile(图形支持,但是可以远程连接)等等;jconsole(可以看到heapsize、permsize+native mem size(这这里叫做:non-heapsize)等等的使用的趋势图)、visualvm(极为推荐的东西,图形化查看,你可以查看到内存单元分配、交换、回收、移动等等整个过程,非常清晰展现jvm的全局资源)、另外pmap可以展现非常清晰的资料,可以精确到某一个java进程内部的每一个细节,而且可以看到heapsize只是其中很小一部分(在solaris操作系统上看得最齐全,LINUX下有些进程可能看不太懂);也可以在/proc/进程号/maps中查看(这里可以看到内存地址单元的起始地址,包含了reserved的地址范围和commited的地址范围),全局资源使用操作系统top命令和free命令看;IBM有一个GCMV免费下载工具也很好;Win32有一个WMMap工具都是很好的工具。

使用相应的工具观察相应的内容,当观察到内存的使用从无到有,上升,然后处于一个平稳趋势,那么这个JVM应该是较为稳定的;如果发现它经过一段平滑期后,又出现飙升,这个必然是有问题的,至于什么问题,根据前面的学下和实际情况我们可以去分析;当它开始后,平滑过程,出现缓慢上升的过程,但是始终会上升到极点,那么一个是需要知道物理内存时候可用,另一个就是少量的内存泄露(JVM现代也有内存泄露,只是它的内存泄露并非C、C++中的内存泄露)。

5、纠正错误:intern()的使用上的错误

最后一章节,我自己纠正一下我自己的错误,以前的文章中,也就是关于intern的使用,最近对他做了一些深入研究,因为以前也是和很多同学一样,听到别人推荐什么就疯狂的使用,知道点原理也是点大概,没有深入研究内部的内容。

我曾经在文章中说到任何系统最多使用的数据类型必然是String,不管做什么,所以在String的处理上很有研究,推荐使用java的朋友在大量使用对比的时候不要用equals,而推荐使用intern(),但是我最近发现我错了,我这里给大家道歉,因为可能会误导很多朋友;下面说明下这个东西为什么?

首先我开始自己怀疑自己的时候是想说,如果intern可以做到高效,那么equals是不是在String中就没有存在的必要了呢,当时对于我理解仅仅为常量池的一个地址对比,好比是两个数字的compare,仅仅需要CPU的单个指令即可完成;于是我开始做了两个实验,一个是最原始,最初级的方法采用单线程循环1000000次调用equals与intern等值对比,并且采用了不同长度的字符串去做比较,发现equals竟然比intern要快,而且随着字符串长度的增加,equals会明显快与intern,然后使用多线程测试也是得到一样的效果,我首先很不敢相信自己坚持的理论被彻底和谐了,后来冷静下来必须需要面对,通过很多权威资料的阅读,我发现我对JVM常量池的理解还只是一点点皮毛而已,所以我做了更加深入的研究。

原来intern方法被调用时是在Perm中的String私有化常量池中寻找相应的内容,而寻找虽然可以通过hash定位到某些较小的链表中,但是还是需要在链表中逐个对比,对比的方法仍然是equals,也就是抛开hash的开销,intern最少要与里面的0到多个对象进行equals操作,而且如果不存在,还要在常量池开辟一块空间来记录,如果存在则返回地址,也就是常量池保证每个String常量是唯一的,这个开销当然大了,而且如果使用在业务代码中将会导致Perm区域的不断增加;

于是,我又反过来想了:既然equals比他效率高,为啥还要用intern呢?而且equals的那个算法对于长字符串逐个字符对比的过程我实在是难以入目;而且也实在是觉得不甘心自己的理论就这么容易被和谐掉,因为自己已经在不少程序中这样用过,这样我岂不是犯下大错了,因为自己参与过的项目的确太多了,而且有类似的代码我写入了框架中,最终发现我可能错了一半,也就是历史上的记录可能我有一半类似的代码是错误的;为什么呢?intern还是有用的,我先做了一个测试,那就是,用一个已经intern好的对象,让他与一个常量做等值,循环次数和上面一样,结果我预料的结果发生了,那就是比equals快出了N多倍数,随着长度的增加,会体现出更加明显的优势,因为intern对比的始终是地址,和长度无关,于是我想到了如何使用它,就是在程序中返回通过字符串类似于数字一样的类型判定时,如:做一个sqlparser的时候,经常根据数据类型做不同的动作,这样如果用equals会在每次循环时付出很多开销,尤其是很多数据库的类型非常多,最坏的是从上到下每个字符串匹配一次,当然长度不等开销很小,长度相等开销就大了;intern我就将这些schema信息预先intern掉,也就是他们已经指向了常量池,当再真正匹配时,就不需要用intern了,而是直接匹配,也就是将这个开销放在初始化的过程中,运行时我们不去增加它的开销。

所以,个人是犯下一个错误,并且以前还很张扬的到处宣传,呵呵,现在觉得有点傻,希望在看到某些推荐用什么新东西的时候,千万不要在没有研究明白他就去用它,甚至于滥用它,至少要经过一些简单的测试,不过对于现代很多复杂的东西,一些简单的测试已经不足以说明问题,就像Lock与Synchronize的开销一样,如果采用简单的循环的话,你会发现新版本的Lock的开销将会比Synchronized的开销更加大,它适合的是并发,读写的并发,所以真正要弄清楚还是研究内在。

最后说下,我个人对JVM的期望,JVM做到了很多个板块之间使用不同的算法,而JVM不希望程序员去关心内存,但是有些特殊的应用需要JVM提供多的支持,当然有些公司对JVM内核进行了改造来适合特殊的应用,但是我们更加希望标准的JVM能够提供更加灵活的内存管理机制,而不仅局限于配置,因为配置适中是死的,在很多时候会面临扩展性的限制;如很多时候我们认为可以判定很多的对象本身就是不会被回收或者根本不容易被回收的,就不用到Young的空间和其他的业务套在一起倒腾了;对于经常做page cache的系统,而page cache的命中率不是特别高(95%以上就很高),也不是很低(如80%以下),这个时候,置换到快不慢的,而会导致在老年代的回收的频繁起来,就我个人希望这些空间都能独立出来,甚至于可以由程序去控制和指定,当然JVM可以自身去默认;尤其是按照一些特殊的对象等级类型或者说对象的大小,这些细节都可以采用一些相应的默认GC手段来完成,也可以人工的指定,当然也在默认情况下可以按照原有的模式进行架构,这样JVM的内存调节的灵活将会更加宽松,使得它能在各类场合下只要使用相对应的手段配置和程序调整都是可以打到目的的。

本文包含大量个人见解,如有不是之处,请大家多多指教!本文到此完结,内容粗而不深入,细节问题,细节讨论。

 常见参数JVM参数配置(java vm Hotspot TM 1.6)

  • -Xms为初始化为HeapSize的空间,即被Commited的尺寸。
  • -Xmx为最大的HeapSize空间,有些尚未被Commited,但是已经被进程所Reserved,当现在已经被Commit的空间长期处于(jdk1.1还有一个-mx为包含handler表的空间)。
  • -Xmn设置Young的空间大小,此时NewSize和MaxNewSize一致,或者分别设置-XX:NewSize=128m
  • -XX:PermSize = 64M及-XX:MaxPermSize= 64M为永久代的初始大小和最大大小。
  • -XX:NewRatio= 3 为Tenured:Young的初始尺寸比例(设置了大小就不再设置此值),此时Young占用整个HeapSize的1/4大小。
  • -XX:SurvivorRatio= 6:为Eden:Survivor比例大小,此时一个Survivor占用Young的1/8大小,而Eden占用3/4大小。
  • -Xss=256k为ThreadStack空间大小,jdk 1.5以后默认是1M,在IBM的jdk中还有-Xoss参数(此时每个线程占用的stack空间为256K大小)
  • -XX:MaxTenuringThreshold=3:一般一个对象在Young经过多少次GC后会被移动到OLD区。
  • -XX:+UseParNewGC:对Yong区域启用并行回收算法。
  • -XX:+UseParallelGC:一种较老的并行回收算法。
  • -XX:+UseParallelOldGC:对Tenured区域使用并行回收算法。
  • -XX:ParallelGCThread=10:并行的个数,一般和CPU个数相对应。
  • -XX:+UseAdaptiveSizepollcy:收集器自动根据实际情况进行一些比例以及回收算法调整。
  • -XX:CMSFullGCsBeforeCompaction= 3:多少次GC后会进行压缩碎片
  • -XX:+UseCmsFullCompactAtFullCollction:打开老年代压缩

以下3个参数为永久带回收参数:

  • -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled
  • -XX:+CMSPermGenSweepingEnabled对永久带进行相应的回收,在jdk1.6中不需要数:-XX:+CMSPermGenSweepingEnabled
  • -XX:MinHeapFreeRatio这是指剩余空间百分比多少时,开始减小commited的内存;
  • -XX:MaxHeapFreeRatio指剩余空间百分比多少时,开始增加commited的内存,直到-Xmx大小。
  • -XX:MaxGCPauseMillis指GC最大的暂停时间,当超过这个时间,那么JVM会适当调整内存比例(前提是使用的是基于比例的YONG和设置)。
  • -XX:+UseConcMarkSweepGC 启动并发GC,一般针对Tenured区域。
  • -XX:+CMSIncrementalMode增量GC,将内存切块,分布在多个局部去GC。
  • -XX:CMSInitiatingOccupancyFraction在并发GC下,由于一边使用,一遍GC,就不能在不够用的时候GC,默认情况下是在使用了68%的时候进行GC,通过该参数可以调整实际的值。

大致的参数设置就这些,但是GC本身的参数还有很多,尤其是和应用或者和具体硬件结合起来的时候,而BEA和IBM也有自己的JDK,这里有些参数他们支持,有些参数不支持,在某些平台和甚至于硬件上可以支持特殊的参数来控制(如在部分intel系列的多CPU机器上,通过它的NUMA架构,可以设置对应参数支撑,节点和CPU之间可以实现分工负载、常规服务上都是SMP的,而大型机上多半是MPP);类似于上面的并发GC在一般情况下是不会进行compact压缩的,因为它希望回收的时间短,但是充满compact的压缩时间必然不是那么短,所以在部分特殊应用下有些使用定宽度的内存尺寸,回收后不管空余内存,因为每个内存的尺寸都是那么大,这样来处理,当然这样必然会导致很多的内存浪费,但是它的好处是可以没有compact而不存在说要分配的内存分配不到的问题。



相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部