整理一期JVM系列, 内容基本来自深入理解JAVA虚拟机这本书, 书本讲的比较详细, 所以把一些关键东西整理一下, 反复学习, 其中对个别地方加入了自己的理解, 如果理解有错误, 还希望大家指出, 必虚心接受, 文章类型实在不知道如何选, 转载需要原文地址, 翻译也不是, 就先选择原创吧
内存区域和内存溢出
1. JVM的运行时数据区域
- 程序计数器: 程序计数器是一块儿比较小的内存空间, 它可以看作是当前线程所执行的字节码的行号指示器, 字节码解释器工作时就是通过改变这个计数器的值来选取下一条所要执行的字节码指令, 循环,跳转,分支等等都需要依赖它完成
因为线程切换后需要恢复到正确的执行位置, 所以每条线程都需要独立的程序计数器, 这块儿内存是私有的 - 栈: hotspot虚拟机将本地方法栈和虚拟机栈合在一起了, Java中每个方法被执行的时候, Java虚拟机都会同步创建一个栈帧用来存储局部变量, 操作数栈, 动态连接, 方法出口等信息, 每一个方法从开始到执行完就对应一个栈桢在虚拟机从入栈到出栈的过程, 虚拟机栈也是线程私有的.
局部变量表存放了编译期间可知的Java虚拟机基本数据类型和对象引用类型以及returnAddress(指向了一条字节码指令的地址) - 堆: Java中所有对象以及数组都在堆上创建并分配内存, 这是一块所有线程共享的内存区域, 堆经常被细分成很多块, 主要是为了更好的回收内存而已
- 方法区(包含运行时常量池): 方法区存储已被虚拟机加载的类型信息, 常量,静态变量, 即时编译器编译后的代码缓存等数据, 也有人把方法区称呼为永久代, 这是因为以前hotspot虚拟机将垃圾收集器的分代设计扩展至方法区, 这样就可以与堆共用垃圾回收器, 在JDK8以后, Java虚拟机采用元空间来代替永久代实现方法区, 并且将字符串常量池转移到了堆中实现.
运行时常量池是方法区的一部分, class文件中除了有类的版本, 字段, 方法, 接口等描述信息外, 还有一项信息是常量池表, 用于存放编译期间生成的各种字面量与符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池, 运行期间也可以将新的常量放到池中, 比如String的intern方法
2. 对象的创建
两种方式
- 指针碰撞
如果java堆中所有内存是规整的, 使用过的在一边,未使用过的在另外一边, 这时候分配内存就仅仅是简单的指针移动而已
- 空闲链表
如果java堆中的内存不规整, 使用过的和未使用的交错在一起, 虚拟机就必须维护一个链表, 用来记录哪些可用, 在分配时找一块儿足够大的空间分配给对象实例
选择哪种方式是由Java堆是否规整决定的, 而Java堆是否规整取决于采用的垃圾回收器是否具有空间压缩整理决定的, 因此使用Serial, ParNew等带有空间压缩整理的垃圾回收器就会使用指针碰撞, 而使用CMS这种基于清除算法的收集器时就只能使用空闲链表来分配内存
如何保证并发情况下分配内存的线程安全问题
- 实际上虚拟机时采用CAS配上失败重试的方式保证更新操作的原子性
- 将内存分配的动作按照线程划分在不同的空间之中进行, 即每个线程在Java堆中预先分配一块儿内存, 称为本地线程分配缓冲(TLAB), 这样就相当于每个线程都有了一块私有的内存, 自然不会出现线程安全问题, 只有当自己的缓冲区用完了, 需要分配新的缓冲区时才需要加锁同步完成
3. 内存溢出
除了程序计数器之外, 虚拟机的其他几个运行时区域都有发生OutOfMemoryError的可能
3.1 Java堆溢出
只要不断创建对象, 并且保证GC Roots到对象之间有可达路径来保证垃圾回收器不会回收这些对象(可以使用List来保存起来, 这样每个对象就都有用了), 就可以产生堆内存溢出, 测试时可以通过如下命令来调整堆大小方便测试
以下指令中-Xms表示最小内存, -Xmx表示最大内存, 将最大内存与最小内存设置一样的值, 可以避免堆自动扩展, -XX:+HeapDumpOnOutOfMemoryError表示在发生OOM异常时Dump出当前内存堆快照以分析
-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
代码如下
public class HeapOOM {
static class OOMObject {}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
Java堆内存溢出报错时, 异常堆栈信息java.lang.OutOfMemoryError会进一步跟随Java heap space
要解决这个问题, 常规的方法是通过内存映像分析工具对dump出来的堆转储快照进行分析, 首先应该确认是内存泄露还是内存溢出
如果是内存泄露, 可进一步通过工具查看泄露对象到GC Roots的引用链, 找到泄露对象是通过怎样的引用路径与哪写GC Roots相关联的才导致垃圾回收器无法回收, 根据泄露对象的类型信息以及GC Roots可以找到对象创建的位置以及代码的具体位置
如果不是内存泄漏, 可以检查虚拟机的对参数-Xms和-Xmx与机器内存相比是否还有可以调整的空间, 还要分析代码, 检查是否有哪些对象的生存周期过长, 存储结构设计不合理等等, 尽量减少程序运行期的内存消耗
3.2 栈溢出
栈容量只能由-Xss参数来设定, 例如-Xss128k, 栈溢出分两种情况
- 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出StackOverflowError异常
- 如果虚拟机的内存允许动态扩展, 当扩展栈容量无法申请到足够的内存时会抛出OutOfMemeoryError异常,
hotspot虚拟机是不支持扩展的, 而一般来说栈都能申请到足够的内存, 所以很少会出现OOM异常, 可能会由于栈深度大于虚拟机所允许的最大深度而抛出StackOverflowError
因为操作系统分配给进程的内存大小是固定的, 所以当这块儿内存去掉设置的堆内存大小, 方法区内存大小和程序计数器大小以及进程本身消耗的内存就是栈可以分配的的内存大小, 因此每个线程分配到的栈内存越大, 就越容易把剩下的内存耗尽, 由于建立过多线程而导致的内存溢出, 在不能减少线程数时, 只能通过减少最大堆内存和最大方法区内存来换取更多的容量
3.3 方法区和运行时常量池溢出
永久代和元空间有什么区别?
永久代和元空间都是对JVM规范中方法区的实现而已, 不过他们的最大区别在于: 元空间并不在虚拟机中, 它使用本地内存, 因此元空间的大小只受本地内存的影响
JDK8以后不再有永久代, 元空间代替了永久代, 并且自JDK7开始, 原本存放在永久代的字符串常量池也被移到了堆中,所以方法区中是比较难出现内存溢出的.