JVM 的内存区域是怎么划分的?
JVM 的内存划分中, 有部分区域是线程私有的, 有部分是属于整个 JVM 进程; 有些区域会抛出 OOM 异常, 有些则不会, 了解 JVM 的内存区域划分以及特征, 是定位线上内存问题的基础. 那么 JVM 内存区域是怎么划分的呢?
首先是程序计数器(Program Counter Register), 在 JVM 规范中, 每个线程都有自己的程序计数器. 这是一块比较小的内存空间, 存储当前线程正在执行的 Java 方法的 JVM 指令地址, 即字节码的行号. 如果正在执行 Native 方法, 则这个计数器为空. 该内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的内存区域.
第二, Java 虚拟机栈(Java Virtal Machine Stack), 同样也是属于线程私有区域, 每个线程在创建的时候都会创建一个虚拟机栈, 生命周期与线程一致, 线程退出时, 线程的虚拟机栈也回收. 虚拟机栈内部保持一个个的栈帧, 每次方法调用都会进行压栈, JVM 对栈帧的操作只有出栈和压栈两种, 方法调用结束时会进行出栈操作.
该区域存储着局部变量表, 编译时期可知的各种基本类型数据, 对象引用, 方法出口等信息.
第三, 本地方法栈 (Native Method Stack) 与虚拟机栈类似, 本地方法栈是在调用本地方法时使用的栈, 每个线程都有一个本地方法栈.
第四, 堆(Heap), 几乎所有创建的 Java 对象实例, 都是被直接分配到堆上的. 堆被所有的线程所共享, 在堆上的区域, 会被垃圾回收器做进一步划分, 例如新生代, 老年代的划分. Java 虚拟机在启动的时候, 可以使用 "Xmx" 之类的参数指定堆区域的大小.
第五, 方法区 (Method Area). 方法区与堆一样, 也是所有的线程所共享, 存储被虚拟机加载的元(Meta) 数据, 包括类信息, 常量, 静态变量, 即时编译器编译后的代码等数据. 这里需要注意的是运行时常量池也在方法区中. 根据 Java 虚拟机规范的规定, 当方法区无法满足内存分配需求时, 将抛出 OutOfMemoryError 异常. 由于早期 HotSpot JVM 的实现, 将 CG 分代收集拓展到了方法区, 因此很多人会将方法区称为永久代. Oracle JDK8 中已永久代移除永久代, 同时增加了元数据区(Metaspace).
第六, 运行时常量池(Run-Time Constant Pool), 这是方法区的一部分, 受到方法区内存的限制, 当常量池无法再申请到内存时, 会抛出 OutOfMemoryError 异常.
在 Class 文件中, 除了有类的版本, 方法, 字段, 接口等描述信息外, 还有一项信息是常量池. 每个 Class 文件的头四个字节称为 Magic Number, 它的作用是确定这是否是一个可以被虚拟机接受的文件; 接着的四个字节存储的是 Class 文件的版本号. 紧挨着版本号之后的, 就是常量池入口了. 常量池主要存放两大类常量:
字面量(Literal), 如文本字符串, final 常量值
符号引用, 存放了与编译相关的一些常量, 因为 Java 不像 C++ 那样有连接的过程, 因此字段方法这些符号引用在运行期就需要进行转换, 以便得到真正的内存入口地址.
class 文件中的常量池, 也称为静态常量池, JVM 虚拟机完成类装载操作后, 会把静态常量池加载到内存中, 存放在运行时常量池.
第七, 直接内存(Direct Memory), 直接内存并不属于 Java 规范规定的属于 Java 虚拟机运行时数据区的一部分. Java 的 NIO 可以使用 Native 方法直接在 java 堆外分配内存, 使用 DirectByteBuffer 对象作为这个堆外内存的引用.
下面这张图, 反映了运行中的 Java 进程内存占用情况:
OOM 可能发生在哪些区域上?
根据 javadoc 的描述, OOM 是指 JVM 的内存不够用了, 同时垃圾收集器也无法提供更多的内存. 从描述中可以看出, 在 JVM 抛出 OutOfMemoryError 之前, 垃圾收集器一般会出马先尝试回收内存.
从上面分析的 Java 数据区来看, 除了程序计数器不会发生 OOM 外, 哪些区域会发生 OOM 的情况呢?
第一, 堆内存. 堆内存不足是最常见的发送 OOM 的原因之一, 如果在堆中没有内存完成对象实例的分配, 并且堆无法再扩展时, 将抛出 OutOfMemoryError 异常, 抛出的错误信息是 "java.lang.OutOfMemoryError:Java heap space". 当前主流的 JVM 可以通过 - Xmx 和 - Xms 来控制堆内存的大小, 发生堆上 OOM 的可能是存在内存泄露, 也可能是堆大小分配不合理.
第二, Java 虚拟机栈和本地方法栈, 这两个区域的区别不过是虚拟机栈为虚拟机执行 Java 方法服务, 而本地方法栈则为虚拟机使用到的 Native 方法服务, 在内存分配异常上是相同的. 在 JVM 规范中, 对 Java 虚拟机栈规定了两种异常: 1. 如果线程请求的栈大于所分配的栈大小, 则抛出 StackOverFlowError 错误, 比如进行了一个不会停止的递归调用; 2. 如果虚拟机栈是可以动态拓展的, 拓展时无法申请到足够的内存, 则抛出 OutOfMemoryError 错误.
第三, 直接内存. 直接内存虽然不是虚拟机运行时数据区的一部分, 但既然是内存, 就会受到物理内存的限制. 在 JDK1.4 中引入的 NIO 使用 Native 函数库在堆外内存上直接分配内存, 但直接内存不足时, 也会导致 OOM.
第四, 方法区. 随着 Metaspace 元数据区的引入, 方法区的 OOM 错误信息也变成了 "java.lang.OutOfMemoryError:Metaspace". 对于旧版本的 Oracle JDK, 由于永久代的大小有限, 而 JVM 对永久代的垃圾回收并不积极, 如果往永久代不断写入数据, 例如 String.Intern()的调用, 在永久代占用太多空间导致内存不足, 也会出现 OOM 的问题, 对应的错误信息为 "java.lang.OutOfMemoryError:PermGen space"
内存区域 | 是否线程私有 | 是否可能发生 OOM |
---|---|---|
程序计数器 | 是 | 否 |
虚拟机栈 | 是 | 是 |
本地方法栈 | 是 | 是 |
方法区 | 否 | 是 |
直接内存 | 否 | 是 |
堆 | 否 | 是 |
堆内存结构是怎么样的?
可以借助一些工具来了解 JVM 的内存内容, 具体到特定的内存区域, 应该用什么工具去定位呢?
图形化工具. 图形化工具的优点是直观, 连接到 Java 进程后, 可以显示堆内存, 堆外内存的使用情况, 类似的工具有 JConsole,VisualVm 等.
命令行工具. 这类工具可以在运行时进行查询, 包括 jstat,jmap 等, 可以对堆内存, 方法区等进行查看. 定位线上问题时也多会使用这些工具. jmap 也可以生成堆转储文件 (Heap Dump) 文件, 如果是在 Linux 上, 可以将堆转储文件拉到本地来, 使用 Eclipse MAT 进行分析, 也可以使用 jhap 进行分析.
关于内存的监控与诊断, 在后面会进行深入了解. 现在来看下一个问题: 堆内的结构是怎么的呢?
站在垃圾收集器的角度来看, 可以把内存分为新生代与老年代. 内存的分配规则取决于当前使用的是哪种垃圾收集器的组合, 以及内存相关的参数配置. 往大的方向说, 对象优先分配在新生代的 Eden 区域, 而大对象直接进入老年代.
第一,? 新生代的 Eden 区域, 对象优先分配在该区域, 同时 JVM 可以为每个线程分配一个私有的缓存区域, 称为 TLAB(Thread Local Allocation Buffer), 避免多线程同时分配内存时需要使用加锁等机制而影响分配速度. TLAB 在堆上分配, 位于 Eden 中. TLAB 的结构如下:
- // ThreadLocalAllocBuffer: a descriptor for thread-local storage used by
- // the threads for allocation.
- // It is thread-private at any time, but maybe multiplexed over
- // time across multiple threads. The park()/unpark() pair is
- // used to make it avaiable for such multiplexing.
- class ThreadLocalAllocBuffer: public CHeapObj<mtThread> {
- friend class VMStructs;
- private:
- HeapWord* _start; // address of TLAB
- HeapWord* _top; // address after last allocation
- HeapWord* _pf_top; // allocation prefetch watermark
- HeapWord* _end; // allocation end (excluding alignment_reserve)
- size_t _desired_size; // desired size (including alignment_reserve)
- size_t _refill_waste_limit; // hold onto tlab if free() is larger than this
从本质上来说, TLAB 的管理是依靠三个指针: start,end,top.start 与 end 标记了 Eden 中被该 TLAB 管理的区域, 该区域不会被其他线程分配内存所使用, top 是分配指针, 开始时指向 start 的位置, 随着内存分配的进行, 慢慢向 end 靠近, 当撞上 end 时触发 TLAB refill. 因此内存中 Eden 的结构大体为:
第二, 新生代的 Survivor 区域. 当 Eden 区域内存不足时会触发 Minor GC, 也称为新生代 GC, 在 Minor GC 存活下来的对象, 会被复制到 Survivor 区域中. 我认为 Survivor 区的作用在于避免过早触发 Full GC. 如果没有 Survivor,Eden 区每进行一次 Minor GC 都把对象直接送到老年代, 老年代很快便会内存不足引发 Full GC. 新生代中有两个 Survivor 区, 我认为两个 Survivor 的作用在于提高性能, 避免内存碎片的出现. 在任何时候, 总有一个 Survivor 是 empty 的, 在发生 Minor GC 时, 会将 Eden 及另一个的 Survivor 的存活对象拷贝到该 empty Survivor 中, 从而避免内存碎片的产生. 新生代的内存结构大体为:
第三, 老年代. 老年代放置长生命周期的对象, 通常是从 Survivor 区域拷贝过来的对象, 不过当对象过大的时候, 无法在新生代中用连续内存的存放, 那么这个大对象就会被直接分配在老年代上. 一般来说, 普通的对象都是分配在 TLAB 上, 较大的对象, 直接分配在 Eden 区上的其他内存区域, 而过大的对象, 直接分配在老年代上.
第四, 永久代. 如前面所说, 在早起的 Hotspot JVM 中有老年代的概念, 老年代用于存储 Java 类的元数据, 常量池, Intern 字符串等. 在 JDK8 之后, 就将老年代移除, 而引入元数据区的概念.
第五, Vritual 空间. 前面说过, 可以使用 Xms 与 Xmx 来指定堆的最小与最大空间. 如果 Xms 小于 Xmx, 堆的大小不会直接扩展到上限, 而是留着一部分等待内存需求不断增长时, 再分配给新生代. Vritual 空间便是这部分保留的内存区域.
那么综上所述, 可以画出 Java 堆内的内存结构大体为:
通过一些参数, 可以来指定上述的堆内存区域的大小:
-Xmx value 指定最大的堆大小
-Xms value 指定初始的最小堆大小
-XX:NewSize = value 指定新生代的大小
-XX:NewRatio = value 老年代与新生代的大小比例. 默认情况下, 这个比例是 2, 也就是说老年代是新生代的 2 倍大. 老年代过大的时候, Full GC 的时间会很长; 老年代过小, 则很容易触发 Full GC,Full GC 频率过高, 这就是这个参数会造成的影响.
-XX:SurvivorRation = value . 设置 Eden 与 Srivivor 的大小比例, 如果该值为 8, 代表一个 Survivor 是 Eden 的 1/8, 是整个新生代的 1/10.
常用的性能监控与问题定位工具有哪些?
在系统的性能分析中, CPU, 内存与 IO 是主要的关注项. 很多时候服务出现问题, 在这三者上会体现出现, 比如 CPU 飙升, 内存不足发生 OOM 等, 这时候需要使用对应的工具, 来对性能进行监控, 对问题进行定位.
对于 CPU 的监控, 首先可以使用 top 命令来进行查看, 下面是使用 top 查看负载的一个截图:
load average 代表 1 分钟, 5 分钟, 15 分钟的系统平均负载, 从这三个数字, 可以判断系统负荷是大还是小. 当 CPU 完全空闲的时候, 平均负荷为 0; 当 CPU 工作量饱和的时候, 平均负荷为 1. 因此 load average 这三个数值越低, 代表系统负荷越小, 如果电脑里只有一个 CPU, 把 CPU 看成一条单行桥, 桥上只有一个车道, 所有的车都必须从这个桥上通过. 那么
系统负荷为 0, 代表桥上一辆车也没有
系统负荷 0.5, 意味着桥上一半路段上有车
系统负荷 1, 意味着桥上道路已经被车占满
系统负荷 1.7, 代表着在桥上车子已经满了(100%), 同时还有 70% 的车子在等待从桥上通过:
从 top 命令的截图中可以看到这三个值机器的 load average 非常低. 如果这三个值非常高, 比如超过了 50% 或 60%, 就应当引起注意. 从时间维度上来说, 如果发现 CPU 负荷慢慢升高, 也需要警惕.
其他的内存, CPU 等性能监控工具的使用, 以一张脑图来展示:
来源: http://www.bubuko.com/infodetail-3446131.html