leduoi

二、深入理解Java虚拟机--自动内存管理(持续更新)

每天按照书本学一点,会把自己的总结思考写下来,形成输出,持续更新,立帖为证

自动内存管理

一、Java内存区域与内存溢出异常

Java与C++在内存控制方面截然不同,因为Java虚拟机有自动内存管理机制,所以Java程序员就牺牲部分内存控制权,来换取编写程序时的便利。虽然不容易出现内存泄漏和内存溢出问题,但还是有必要学习点Java虚拟机相关知识,除了在遇到虚拟机问题时可以快速解决之外,还可以和别人装逼(最大的快乐!)
Java虚拟机在运行Java程序的时候,会将内存自动划分为不同区域,不同区域对应的功能、创建销毁时间也不同,有些区域会随着虚拟机启动而一直存在,有些区域以来用户的线程启动结束而创建销毁。

内存区域分为以下几个区域:

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • Java堆
  • 方法区
  • 运行时常量池
  • 直接内存

程序计数器

  • 程序计数器是:一小块内存空间,记录当前线程执行字节码指令的地址,字节码解释器就是通过改变计数器里面的值来确定下一条需要执行的字节码指令
  • “线程私有”的内存空间:在任何确定时刻,处理器都只会执行一个线程中的指令(注:Java的多线程是通过切换线程,分配处理器执行时间来实现的),每个线程都需要记录执行到那条指令了,接下来该执行哪条,所以每个线程都会有一个线程计数器,而且独立存储互不干扰
  • 存储内容:
    • 如果线程正在执行Java方法,则计数器记录的是正在执行虚拟机字节码的指令地址
    • 如果正在执行本地方法(Native),则计数器为空
  • 此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域

Java虚拟机栈

  • Java虚拟机栈:描述的就是在执行Java方法时线程内存模型。每个方法在被调用的时候都会同步创建一个"栈帧",存储到Java虚拟机栈中,这个栈帧里面包含:局部变量表操作数帧动态连接方法出口等信息,一个方法从被调用开始执行到执行完毕,就对应这栈帧从入栈到出栈过程。
  • 线程私有内存空间:和程序计数器一样时线程私有的,生命周期和线程同步。

思考:了下为什么也是线程私有的?应该时每个线程执行方法不同,里面的一些临时变量等也不会相同,为了在切换线程时不会发生混乱互相干扰,所以需要和程序计数器一样,也是线程私有的内存空间

  • 会有人把Java虚拟机内存空间笼统的划分为"栈空间","堆空间",这里说的"栈空间"通常就是指Java虚拟机栈,在笼统一点通常指的是Java虚拟机栈里面的局部变量表这部分
  • 局部变量表:
    • 存放内容:存放了编译器基本数据类型对象引用(并不是对象本身,可能是只想对象起始地址的指针,也可能是指向一个代表对象的句柄???,或者其他于此对象相关的位置),returnAddress类型(返回地址类型,指向一条字节码指令的地址)
    • 局部变量表中的存储空间:都是以局部变量槽(Slot)来表示,其中64位长度的long和double类型数据占两个变量槽,其余数据类型占一个。
    • 在程序编译期间就已经确定好了局部变量表的大小并完成分配。当进入一个方法时,该方法需要在局部变量表中分配多大的空间都是确定好的,在运行期间不会改变局部变量表的大小。
    • 上面所说的”大小“是指局部变量槽的数量,不同虚拟机的一个变量槽可能会占不同大小内存空间(一个变量槽占32比特或64比特)
  • 异常:《Java虚拟机规范》对该内存区域规定了两个异常:
    • 如果线程请求栈的深度大于虚拟机所允许的深度,则会抛出*Error异常;
    • 如果Java虚拟机栈的容量可以动态扩展,当栈扩展时无法申请到足够的内存就会抛出OutOfMemoryError异常

本地方法栈

本地方法栈与Java虚拟机栈作用相似,但也稍有区别。Java虚拟机栈是为虚拟机执行Java方法(字节码)服务的,而本地方法栈是为虚拟机执行本地方法(Native)服务的。

因为在《Java虚拟机规范》中,并没有对本地方法栈做强制规定,所以不同虚拟机实现的方式可能不同,有些虚拟机(HotSpot虚拟机)直接将本地方法栈和Java虚拟机栈合二为一

与Java虚拟机栈一样,当本地方法栈深度超出规定(溢出)和栈扩展失败的时候,也会报*Error和OutOfMemoryError异常

Java堆(Java Heap)

Java堆是虚拟机管理内存中最大的一块,被所有线程共享,在虚拟机启动时创建。

主要是负责存放对象实例,按照《Java虚拟机规范》描述是:所有对象实例以及数组都应当在堆上分配。考虑到Java语言的发展,和即时编译技术的出现,未来可能会出现对象实例不在堆上分配的情况。

Java堆是垃圾收集器管理的内存区域,因此也被称为"GC堆"。垃圾收集器大部分是基于分代收集理论设计的,所以会出现新生代、老年代、永久代,Eden空间、Form Survivor空间、To Survivor空间等名词,这些划分的区域仅仅是垃圾收集器共同特性或设计风格,并不能说是Java堆是由这些区域组成的。

从分配内存角度说,线程共享的Java堆可以划分多个线程私有的分配缓冲区(TLAB),划分出来的唯一作用还是存放对象实例,目的是为了更快更好的分配和回收内存。

Java堆在逻辑上是连续的,但在物理上并不要求连续。如果存放的是大对象,例如:数组对象,大多数虚拟机为了实现简单、存储高效,可能会要求连续的存储空间。

Java堆既可以是固定大小,也可以是可扩展的。目前主流虚拟机都是可扩展的,通过参数-Xmx和-Xms设定。如果在Java堆中没有内存给对象实例分配,并且无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

方法区

在《Java虚拟机规范》中对方法区的约束是十分宽松的,许多部分和Java堆相同,例如:

  • 都是线程共享
  • 物理上不需要连续的存储空间
  • 可以选择固定大小或可扩展

并把方法区描述为堆的一个逻辑部分,但是方法区和堆还是有区别的,方法区的另一个别名叫"非堆(Non-Heap)",方法区用来存放已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

方法区与永久代关系

本质上两者并不是等价的,但很多人将两者混为一谈,这是因为当初HotSpot虚拟机在设计的时候,为了简单方便可以像管理Java堆一样管理这部分内存,将垃圾收集器的分代设计扩展至方法区,即使用永久代来实现方法区。但Java虚拟机规范中并没有对方法区的实现做具体要求,所以其他虚拟机(如:BEA的JRockit、IBM的J9)都没有永久代这个概念。

使用永久代实现方法区好处:

可以像管理Java堆一样管理一部分内存,省去了专门为方法区编写管理代码的工作

使用永久代实现方法区坏处:

会导致Java应用更容易遇到内存溢出的问题,永久代有-XX:MaxPermSize的上限,即使没有设置也有默认值,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中4GB限制,就不会出现问题。

有极少数方法(String::intern())会因永久代的原因而导致不同虚拟机下有不同表现

永久代介绍

垃圾收集行为在永久代很少出现,但并不是数据进入永久代之后就永久存在了,这一区域内存回收目的主要是针对常量池回收和对类型的卸载,但是因为回收条件严格,所以回收效果总不能令人满意。

  • JDK6的时候,HotSpot开发团队计划放弃使用永久代,逐步改为采用本地内存(Native Memory)来实现方法区
  • JDK7的时候,把原本放在永久代的字符串常量池、静态变量移出
  • JDK8的时候,放弃使用永久代,在本地内存中实现元空间(MetaSpace)来代替,并把JDK7中还保留在永久代中的内容全部移出。

当方法区无法满足新内容内存分配的时候,就会抛出OutOfMemoryError异常。

运行时常量池

运行时常量池是方法区的一部分,用来存放编辑时生成的各种字面值和符号引用,因为《Java虚拟机规范》并没有对这部分做详细要求,所以虚拟机开发者可以按照自己需求去实现这部分内存。除了上面戳的符号引用外,一般还会将符号引用翻译出来的直接引用也存到运行时常量池中。

具备动态性。并不一定是预置入Class文件中常量池才能进入方法区的运行时常量池,运行期间可以将新的常量放入。

当无法申请到足够内存时,会抛出OutOfMemoryError异常。

直接内存

直接内存并不是虚拟机运行时数据区(上面写的都是)的一部分,也不是《Java虚拟机规范》中定义的内存。

用力提高性能,避免在Java堆和Native堆中来回复制数据。

直接内存并不受Java堆内存大小的限制,但是受本机总内存的限制。根据实际内存设置-Xmx等参数时,如果忽略直接内存,可能会导致抛出OutOfMemoryError异常。

二、HotSpot虚拟机对象探秘

1、对象创建

  1. 类加载:Java虚拟机遇到new指令的时候,首先会去常量池定位一个类的符号引用,并检查这个类是否已经被加载,解析和初始化过。如果没有则进行类加载过程

  2. 分配内存:在类加载之后,就知道对象所需要的内存大小,接下来开始为对象分配内存。对象分配内存是在堆上完成的,划出一块未使用的内存给对象,分配的方式有两种:"指针碰撞","空闲列表"。到底采用哪种分配方式取决于Java堆是否规整,Java堆是否规整又取决于垃圾收集器是否带有空间压缩整理(Compact)能力。

    分配内存中为了解决线程安全问题有两种方案:一、对分配内存空间的动作进行同步处理,即虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。二、使用本地线程分配缓冲区(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区进行分配,只有缓冲区用完了,在分配新的缓冲区的时候才需要同步锁定。

  3. 赋初始值:保证对象的实例字段不赋初始值就可以直接使用,可以直接访问这些字段的初始值。

  4. 虚拟机对对象设置:虚拟机会将一些必要信息保存在对象头中,如:这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码等等。

  5. 执行构造函数:此时站在虚拟机角度看对象已经创建好了,但是此时对象中字段还是默认零值,需要执行构造函数,按照设计意图构造好。

2、对象的内存布局

对象在堆内存中的存储布局分为三个部分:对象头、实例数据、对齐填充

  • 对象头:存储两类信息。一是用来存储对象自身运行时数据,如:哈希码、GC分代年龄、锁状态标志等;二是类型指针,Java虚拟机通过这个指针确定该对象是哪个类的实例,但并不是必须保留类型指针。

  • 实例数据:存储对象真正有效信息,即定义的各种类型字段内容。

  • 对齐填充:仅仅起着占位符的作用。HotSpot虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,对象头一定是8字节的整数倍,如果实例数据部分不是整数倍,就需要通过对齐填充补全。

3、对象的访问定位

主流的对象访问方式主要有两种:使用句柄和直接访问

三、实战

待补充...

分类:

技术点:

相关文章: