一、Java内存分区
JVM内存分区分为:
- 程序计数器:存储当前线程执行的字节码地址,每个线程私有,用于实现虚拟机执行指令的跳转等。 如果执行的是Native方法,计数器则为空。
- 虚拟机栈:每个方法执行时,都会创建一个栈帧,栈帧中存储了局部变量表,操作数栈,动态连接,方法出口等信息。局部变量表中一个double,long类型对象占两个slot(变量槽:局部变量表中的空间单位,对于32位以内的,用1个来存储,64位的是两个)。如果线程请求的栈深度大于虚拟机允许的深度,会抛出StackOverflowError,如果栈申请不到足够的内存,则会抛出OutOfMemoryError。虚拟机参数设置:-Xss
- 本地方法栈:与虚拟机栈类似,只不过是为本地方法服务的。由于Java虚拟机规范没有对其进行严格规定,所以很多虚拟机直接将本地方法栈和虚拟机栈合二为一。
栈有一个很重要的特殊性,就是存在栈中的数据可以共享
假设我们同时定义 int a = 3; int b = 3;
编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将a指向3的地址。接着处理int b = 3;在创建完b的引用变量后,由于在栈中已经有3这个字面值,便将b直接指向3的地址。这样,就出现了a与b同时均指向3的情况。
特别注意的是,这种字面值的引用与类对象的引用不同。假定两个类对象的引用同时指向一个对象,如果一个对象引用变量修改了这个对象的内部状态,那么另一个对象引用变量也即刻反映出这个变化。相反,通过字面值的引用来修改其值,不会导致另一个指向此字面值的引用的值也跟着改变的情况。
如上例,我们定义完a与 b的值后,再令a=4;那么,b不会等于4,还是等于3。在编译器内部,遇到a=4;时,它就会重新搜索栈中是否有4的字面值,如果没有,重新开辟地址存放4的值;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。 - Java堆:虚拟机所管理的内存最大的一块。Java堆是线程共享的一块内存区域,此区域的唯一用处就是存放对象实例,Java中几乎所有的对象都是在这里分配内存的。如果堆无法完成实例的分配且无法进一步无法扩展,则会抛出OutOfMemoryError。
虚拟机参数设置:
最大值:-Xmx
最小值:-Xms
两个参数设置成相同的值可避免堆自动扩展。 - 方法区:类似于Java堆,是各个线程共享的一块内存区域,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 直接内存:在JDK1.4中加入了NIO类,引入了基于通道与缓冲区的IO方式,它可以使用Native方法直接分配堆外内存,从而避免在堆外内存和Java堆之间来回拷贝。直接内存受物理内存限制,当动态扩展失败时,会抛出OutOfMemoryError。
二、内存分配
Java的实例分配的算法一般分为两种。
- 指针碰撞:维护一块大的内存,之后每次分配都从内存头部切出一小块内存。
- 空闲列表:将空闲的内存块放入列表中。每次分配内存的时候就从中挑选一个足够大的内存划分给对象实例。
一般指针碰撞的方式更加高效,但是对垃圾回收算法提出了更高的要求,垃圾回收器必须带有空间压缩整理的能力,比如使用Serial、ParNew等待压缩整理过程的收集器时,系统采用的分配算法是指针碰撞;而当使用CMS这种基于清理算法的收集器时,理论上只能采用空闲列表算法。
但是由于Java是支持并发的,并发的分配对象,也就意味着我们的实例分配算法需要是线程安全的。有两种解决方案:
- 对分配内存的动作进行同步处理(实际HotSpot的实现方案)。
- 为每个线程分配一个小块的分配缓冲区,每次线程分配对象先从自己的缓冲区中通过指针碰撞算法进行分配。分配新的缓冲区时才需要同步操作。
三、对象内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。
对象头:
- 第一部分:存储对象自身运行时的数据,HashCode、GC分代年龄等(Mark Word);
- 第二部分:类型指针,指向它的类元数据的指针,虚拟机通过这个指针来判断这个对象是哪个类的实例(HotSpot 采用的是直接指针的方式访问对象的);
- 如果是个数组对象,对象头中还有一块用于记录数组长度的数据。
实例数据:
- 默认分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers),相同宽度的字段会被分配在一起,除了 oops,其他的长度由长到短;
- 默认分配顺序下,父类字段会被分配在子类字段前面。
对齐填充:
- 由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8的整数倍,对齐填充是放在对象尾部的一段起占位符作用的内存。
四、引用
Java程序会通过栈上的reference来操作堆上的数据,由于引用类型在《Java虚拟机规范》里面只规定了它是指向对象的引用,而没有定义如何引用,所以也取决于虚拟机的具体实现。主流的方式是句柄引用和直接指针两种。
句柄访问:Java堆中会划出一块内存作为句柄池,reference中存储的是句柄地址,而句柄中存储的实际对象地址。
直接指针访问:reference存储的对象在堆上的地址。
句柄访问的优势是在移动对象的时候只需要修改句柄中的数据即可,而需要操作程序栈。而直接指针访问的优势是一步到位,比句柄访问少一次内存访问,性能更加快。HotSpot中使用的是直接指针访问。
在这里
插入图片描述
JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用,软引用、弱引用、虚引用,强度递减。
- 强引用:一般我们的变量就是强引用,被强引用的对象是不能被回收的。
- 软引用:软引用描述的是有用但不是必要的对象。在系统发生内存溢出前,会将这些对象列入回收范围之中进行二次回收,只有二次回收还没有足够的内存,才会抛出内存溢出异常。(Jdk1.2中的SoftReference)
- 弱引用:弱引用用来描述那些非必须的对象,它的强度要比软引用更弱。弱引用只能存活到下一次垃圾回收发生为止。当垃圾收集器工作时,不管内存是否足够,都会把仅被弱引用所引用的对象回收掉。(Jdk1.2中的WeakReference)
- 虚引用:虚引用时最弱的引用。一个对象是否有虚引用在引用它,都不影响它的生存时间,也无法通过虚引用获得一个对象实例。虚引用的用处是在这个对象被回收的时候收到一个系统通知。(Jdk1.2中的PhantomReference)
五、内存回收
Java栈空间会随着线程的消亡而消亡,而栈帧在方法返回后就会被销毁,因此不需要回收。
真正需要内存回收的是Java堆和方法区。哪些对象应该被回收,这只有在运行期间才能得知。垃圾回收期器关心的就是这部分内存的管理。
六、判断垃圾
要实现垃圾回收,垃圾回收器一定要有能力确定哪些对象是垃圾。下面是几种传统的方案:
- 引用计数法:为每个对象增加一个计数器,如果一个reference指向某个对象,这个对象计数器就加1,而原来对象的计数器就减少1。如果一个对象的引用为0,则它就不再可能被访问,可以作为垃圾回收。引用计数法的优势是实现简单,实时回收,但是缺点是无法解决循环引用的问题(a引用b,b引用a,这两个对象永远不会被释放)。微软的COM技术,Python语言等用的就是引用计数算法来实现对象内存回收。
- 可达性分析法:基于图论,一个对象可以再次被访问,当且仅当从GC根可以通过引用链抵达这个对象。可以作为GC根的对象包括:
栈中的引用的对象
类静态变量
方法区中的常量
本地方法栈中JNI引用的对象
所有被同步锁持有的对象
本地代码缓存
JMXBean、JVMTI中注册的回调
上面的GC根并不完全,还可以加入一些其它对象。比如在执行分代回收的时候,虽然我们仅清理某个内存区域,但是这个内存区域中的对象可能被其它内存区域中的对象所引用,因此我们需要将其它内存区域中的对象也加入作为GC根。
实际上即使是在可达性分析中判定不可达的对象,也不是必定会被回收的。在可达性分析中被确定为不可达的对象,它将会被第一次标记,随后进行一次筛选,如果对象覆盖了finalize方法且方法未被调用过,那么对象就会被放置在一个名字为F-Queue的队列中,之后由一个虚拟机自动建立的、低调用优先级的Finalizer线程去执行它们的finalize方法。这里说的执行是虚拟机会触发这个方法开始运行,但是不保证会等待它运行完成。稍后收集器会对F-Queue中的对象进行第二次小规模的标记,如果此时对象依旧不可达,则就会被回收,否则逃过一劫。
除了上面提到的堆空间的内存回收外,实际上方法区也会进行内存回收。《Java虚拟机规范》中并不强制要求虚拟机在方法区中实现垃圾回收。且方法区即使进行回收,一般由于回收条件的严苛,也是收效甚微的。
方法区中可以回收的内容如下:
废弃的常量:如果常量不再被引用,且虚拟机中没有其它字面量引用它,就可以被回收。
不再被使用的类型:类及所有派生子类的所有实例都被回收,类的类加载器被回收,java.lang.Class对象不再被引用。满足上面这三个条件,类型就可以被回收。
七、分代回收
当前商业虚拟机的垃圾收集器,大多数都遵循了分代收集的理论仅设计,它建立在两个假说上:
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:经历过越多次垃圾回收过程的对象越难消亡。
根据这两个假说,收集器应该将Java堆按照年龄划分为若干个区域。这样对存储对象年级较小的分区,可以更加高频的进行垃圾回收(能释放出更多的空间),而对于对象年级较大的分区,则可以低频的进行垃圾回收。
设计者一般会将Java堆至少划分为新生代、老年代。每次新生代没有被回收的对象移动到老年代中去。
由于即使进行了分代,但是可能存在跨代引用,因此即使仅回收新生代,也需要遍历所有老年代的对象,这样和全局回收差别不大。因此需要下面的假说:
跨代引用假说:跨代引用相对于同代引用占极少数。
根据上面这个假说,只需要在新生代建立一个全局的数据结构(该结构称为记忆集),这个结构把老年代划分成若干个小块,并标志哪些块中存在跨带引用。之后对新生代进行回收的时候,就可以跳过大部分老年代的块,仅将少部分块中的对象加入到GC根中即可。这种方式增加了一些运行时的开销,但是比起收集时扫描整个老年代来说仍然很划算。
一般垃圾回收过程根据其回收区域分成:
Young GC:仅回收新生代
Old GC:仅回收老年代
Mixed GC:回收老年代和新生代
Full GC:回收整个Java堆和方法区
八、Class 文件的组成结构
Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件中,中间没有任何分隔符。Java 虚拟机规范规定 Class 文件采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,我们之后也主要对这两种类型的数据类型进行解析。
Class 文件的头 8 个字节
Class 文件的头 8 个字节是魔数和版本号,其中头 4 个字节是魔数,也就是 0xCAFEBABE,它可以用来确定这个文件是否为一个能被虚拟机接受的 Class 文件(这通过扩展名来识别文件类型要安全,毕竟扩展名是可以随便修改的)。
后 4 个字节则是当前 Class 文件的版本号,其中第 5、6 个字节是次版本号,第 7、8 个字节是主版本号。
常量池:
从第 9 个字节开始,就是常量池的入口,常量池是 Class 文件中:
与其他项目关联最多的的数据类型;
占用 Class 文件空间最大的数据项目;
Class 文件中第一个出现的表类型数据项目。
常量池的开始的两个字节,也就是第 9、10 个字节,放置一个 u2 类型的数据,标识常量池中常量的数量 cpc (constant_pool_count),这个计数值有一个十分特殊的地方,就是它是从 1 开始而不是从 0 开始的,也就是说如果 cpc = 22,那么代表常量池中有 21 项常量,索引值为 1 ~ 21,第 0 项常量被空出来,为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”时,将让这个索引值指向 0 即可。
常量池中记录的是代码出现过的所有 token(类名,成员变量名等,也是我们接下来要修改的地方)以及符号引用(方法引用,成员变量引用等),主要包括以下两大类常量:
字面量: 接近于 Java 语言层面的常量概念,包括
文本字符串
声明为 final 的常量值
符号引用: 以一组符号来描述所引用的目标,包括
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
常量池中的每一项常量都通过一个表来存储。目前一共有 14 种常量,不过麻烦的地方就在于,这 14 种常量类型每一种都有自己的结构,我们在这里只详细介绍两种:CONSTANT_Class_info 和 CONSTANT_Utf8_info。
CONSTANT_Class_info 的存储结构为:
… [ tag=7 ] [ name_index ] …
… [ 1位 ] [ 2位 ] …
其中,tag 是标志位,用来区分常量类型的,tag = 7 就表示接下来的这个表是一个 CONSTANT_Class_info,name_index 是一个索引值,指向常量池中的一个 CONSTANT_Utf8_info 类型的常量所在的索引值,CONSTANT_Utf8_info 类型常量一般被用来描述类的全限定名、方法名和字段名。它的存储结构如下:
… [ tag=1 ] [ 当前常量的长度 len ] [ 常量的符号引用的字符串值 ] …
… [ 1位 ] [ 2位 ] [ len位 ] …