JVM内存区域划分
程序计数器(Program Counter Register)
可以看做是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选择下一条执行的指令。 分支、循环、跳转、异常处理等都需要依赖这个计数器来完成。
由于JVM虚拟机的多线程是通过线程轮流切换并分配CPU执行时间来实现的。 所以在任何一个时刻,一个处理器志会执行一条线程中的指令。 所以为了线程切换后能恢复到正确的执行位置, 每个线程都需要有一个独立的程序计数器, 各个线程之间的计数器互不影响, 独立存储。 我们称这类内存为 线程私有内存。
如果线程执行的是一个Java方法, 则计数器记录的是正在执行的虚拟机字节码的地址。 如果正在执行的native方法, 则计数器值为空, 此内存区域是唯一一个在jvm规范中没有规定任何OOM的情况的区域。
虚拟机栈(VM stack)
虚拟机栈也是线程私有的。
虚拟机栈描述的是Java方法执行的内存模型: 每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 每个方法从调用直到执行完成的过程, 就对应着一个栈帧在虚拟机栈的入栈到出栈的过程。
在jvm规范中, 对这个区域规定了两种异常情况: 如果线程请求的栈审图大于当前虚拟机允许的栈深度, 则抛出StackOverflowError异常。 如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存, 则会抛出OOM的异常。
局部变量表: 存放了编译期间各种基本数据类型、对象引用类型、和returnAddress类型(指向了一条字节码指令的地址)
本地方法栈
类似于虚拟机栈, 只不过本地方法栈为虚拟机执行native方法服务。 在Hotspot虚拟机中, 本地方法栈和虚拟机栈是在一起的。 通过 -Xss参数设置。
堆
堆是被所有线程所共享的一块内存区域。 几乎所有的对象实例全部都在这里分配内存。 同时Java堆也是垃圾回收器主要管理的区域, 也叫GC堆。
由于目前的垃圾回收器基本都是采用了分代收集的算法, 所以堆内存还可以细分为: 新生代和老年代; 再细致一些还有Eden空间,From Survivor空间、To Survivor空间等。
根据jvm虚拟机规范的规定,Java堆可以处理物理上不连续的内存空间, 只要逻辑上连续即可。如果堆中没有内存完成实例分配并且也无法扩展时, 将会抛出OOM异常。
方法区
方法区是各个线程共享的内存区域。 它用于存储已经被JVM加载的类信息、常量、静态变量等数据, 也就是永久代(永久代是针对Hotspot虚拟机来说的,
对于其他虚拟机是不存在永久代的概念的)。
jvm规范对此区域的限制较为宽松, 除了和堆一样不需要连续的物理内存之外,还可以选择不实现垃圾回收。 所以相对而言此区域的垃圾回收行为是比较少见的。
但也不像“永久代”这么名称说的数据进入此区域就永远存在了。 这个区域的内存主要针对常量池的回收和类型的卸载。
当方法区无法满足内存分配的需求时, 将抛出OOM异常。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等信息之外,
还有常量池, 用于存放编译期间生成的各种字面变量以及符号引用。 这部分内容在类加载后进入方法区的运行时常量池进行存放。
运行时常量池相对于Class文件常量池的的另外一个重要特征是具备动态性。 JAVA语言并不要求常量只有在编译期间产生, 运行期间也可能将新的变量放入到常量池中。
如String的intern()方法。
运行时常量池内存不足时也会抛出OOM异常。
直接内存
直接内存并不是jvm运行时数据区的一部分, 也不是jvm规范中定义的内存区域。 但是这部分内存也被频繁使用, 而且也会导致OOM出现, 所以一起讲解。
直接内存, 是java的nio中,可以直接使用native函数分配的堆外内存,通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
这样能够显著提高在一些场合的性能。
直接内存也是内存, 虽然不会收到java堆内存大小的限制,但是会收到本机总物理内存的限制。 如果配置时忽略了直接内存,导致各内存区域总和大于物理内存限制,
就会出现OOM异常。
内存泄漏与内存溢出
内存泄漏 指的是大量无用的对象占用了多过的内存导致内存不足。 即对象不再使用了,但是还有地方在引用它导致GC无法回收。
内存溢出 指的是大量的在用的对象,占用了过多的内存导致内存不足。
Java堆溢出
OOM异常。 如果不存在内存泄漏,检查是否可以增大堆参数, 对象生命周期是否过长等
虚拟机栈和本地方法栈溢出
Hotspot虚拟机不区分虚拟机栈和本地方法栈。 所以-Xoss(设置本地方法栈大小)参数其实是无效的。 栈容量只由-Xss参数设定。
JVM规范描述了两种异常情况:
- 如果线程请求的栈深度大于虚拟机允许的最大深度, 则抛出StackOverflowError.
- 如果虚拟机栈在扩展时候无法申请到足够的内存, 则抛出OutOfMemoryError.
作者测试, 单线程情况下, 无论是栈帧太大还是虚拟机栈容量太小, 当内存无法分配的时候, 虚拟机抛出的都是StackOverflowError.
如果是多线程情况下, 通过不断建立线程倒是可以产生内存溢出异常。 但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系。 或者准确的说,
为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。 因为操作系统给每个进程的内存是有限制的, 例如Windows是2G, 2G减去Xmx(最大堆内存),
再减去MaxPermSize(最大方法区容量), 那么剩下的内存就被虚拟机栈和本地方法去瓜分了。 每个线程分配到的栈容量越大, 可以建立的线程数量就越少。
所以, 如果是由于建立的线程过多导致的OOM, 在不能减少线程数量或者更换64位虚拟机的情况下, 可以通过减少最大堆和减少栈容量来换取更多的线程。
方法区和运行时常量池溢出
String.intern()方法作用是将当前字符串加入到常量池, 如果常量池有了则直接返回常量池中的对象引用。
在jdk1.6以及以前, 可以通过-XX:PermSize以及-XX:MaxPermSize限制方法区大小。
直接内存溢出
直接内存容量可通过-XX:MaxDirectMemorySize指定, 如果不指定, 则默认与-Xmx一样。
垃圾回收器与内存分配策略
判断对象已死(可回收)
-
引用计数器
缺点, 无法解决循环引用问题。 主流jvm虚拟机未采用此方法。 -
可达性分析
主流垃圾回收器采用。 基本思想是通过一系列成为GC Roots
的对象作为起始点,从这些节点向下搜索,搜索过的路径成为引用链。
当一个对象到GC Roots
没有任何引用链相连(从GC Roots
到此对象不可达)时,证明此对象是不可用嘚。Java中,可以作为
GC Roots
的对象有如下几种:- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(native方法)引用的对象。
引用
-
Strong Reference(强引用)
最常用的, 类似于Object a = new Object()
创建的引用就是强引用,只要是强引用还存在, 则对象不会被回收。 -
Soft Reference(软引用)
软引用描述的是一些有用但是非必须的对象。 在系统将要发生OOM之前,会回收这部分对象。 如果回收之后内存还是不足,才会抛出OOM -
Weak Reference(弱引用)
弱引用的用来描述非必须的对象, 被弱引用的对象在下一次垃圾回收的时候一定会被回收。 -
Phantom Reference(虚引用)
也成为幽灵引用。 是最弱的一种引用关系。 一个对象是否有虚引用, 不会对其生存时间构成影响。 也无法通过虚引用来取得一个对象实例。 虚引用的唯一目的就是在这个对象
被回收器回收之前收到一个系统通知。
关于finalize方法
如果对象没有与GC Roots相连接的引用链, 那么它将会被第一次标记并进行一次筛选, 如果对象已经执行过finalize()方法或者没有覆盖finalize方法,
则虚拟机不会再次执行此方法。
如果这个对象可以执行finalize方法, 则jvm会将此对象放置于一个F-Queue的队列, 并有一个虚拟机自动建立的、低优先级的Finalizer线程执行它。 这里的执行是指虚拟机会
触发finalize方法,但是并不保证等待它执行结束。对象可以再finalize方法中将自己(this)关联到某个引用,拯救自己一次,避免被回收的命运。注意finalize方法仅会被调用一次。
不建议使用。
回收方法区(永久代)
方法区的回收次数比较少,主要是因为回收一次并不能释放多少空间。
方法区回收的对象主要分为两部分: 废弃常量和无用的类。
回收废弃常量
回收废弃常量与回收Java堆中的对象十分类似。 比如说常量池中有一个字符串'abc', 但是系统中没有任何一个String对象是叫abc的, 换句话说, 没有任何String对象引用常量池中的
"abc"常量, 也没有任何其他地方引用了这个字面量, 这个时候, "abc"常量就可以被清理。 其他类、方法、字段的符号引用也是类似。
回收无用的类
同时满足一下3个条件,会被认为是"无用的类":
- 该类的所有实例都已经被回收, Java堆中不存在该类的任何引用
- 加载该类的ClassLoader已经被回收
- 该类对用的java.lang.Class对象没有在任何地方被引用, 无法再任何地方通过反射访问该类的方法
满足上述3个条件的类可以被回收。 具体是否回收,还取决于jvm参数, Hotspot虚拟机提供了参数 -Xnoclassgc来控制是否回收。
还可以使用-verbose:class 以及-XX:+TraceClassLoading、-XX:+TraceClassUnloading查看类的加载和卸载信息。
在大量使用反射、动态代理、动态生成JSP等频繁自定义ClassLoader的场景中需要虚拟机具备类卸载功能, 以避免永久代溢出。
垃圾回收算法
-
标记-清除算法
缺点: 标记、清楚效率都不高; 产生大量不连续的内存碎片。 空间碎片太多可能导致以后程序在运行过程中需要分配较大对象的时候, 无法找到足够连续的内存而提前触发
另一次的垃圾回收动作。 -
复制算法
Eden区、From Survivor区、To Survivor区模型。实现简单,运行高效。
缺点:将内存缩小为了原来的一半。目前主要用于回收新生代。
复制的时候, 如果Survivor区空间不够用,剩余待复制的对象将直接进入到老年代。 -
标记-整理算法
Mark-Compact, 类似于标记-清除算法, 只不过标记之后不是清除,而是将存活对象移动到内存的一端。 然后直接清除掉边界以外的区域。
分代收集
一般是新生代采用复制算法, 老年代采用标记-整理或者标记-清除算法。
垃圾回收器
并行: 多条垃圾回收线程一起工作, 但是用户线程还是处于等待状态;
并发: 垃圾回收线程与用户线程一起执行
-
Serial垃圾回收器
-
ParNew垃圾回收器 - 只有它能配合CMS垃圾回收器工作
-
Parallel Scavenge
新生代垃圾回收器, 也是使用标记复制算法, 也是并行的多线程回收器。Parallel Scavenge回收器主要关注吞吐量
(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间))。 而CMS最主要关注停顿时间, 停顿时间越小,用户体验越好,所以CMS适合与用户交互的程序。
Parallel Scavenge 更高效的利用CPU时间, 能尽快完成计算任务, 用于后台运算、没有太多交互的任务。 -
Serial Old回收器
标记-整理算法。Serial的老年代回收器版本。 单线程。可以jdk1.5以及之前搭配Parallel Scavenge 收集器使用,或者错位CMS回收器的后备预案。 -
Parallel Old 回收器
Parallel Scavenge 的老年回收版本。搭配使用。 -
CMS
阶段:- 初始标记 STW
- 并发标记
- 预处理
- 重新标记 STW
- 并发清理
缺点:
- 对CPU资源敏感
- 浮动垃圾
CMS垃圾回收器在并发清理阶段, 用户线程还在运行, 还会产生新的垃圾, 但是CMS无法在当次垃圾回收中清理,只能在下一次垃圾回收时清理。所以这部分垃圾成为浮动垃圾。
CMS无法像其他垃圾回收一样, 等老年代几乎填满了才进行回收, 因为它还要预留一部分内存供并发回收时用户线程使用。
jdk1.5, CMS默认在内存使用率达到68%左右就启动了垃圾回收,jdk1.6, 92%左右时才会启动。如果CMS运行期间预留的内存无法满足程序需要,
就会出现“Concurrent Mode Failure”失败, jvm将启动后预案: 临时启用Serial Old回收器, 这样的话停顿时间就长了。所以此阈值设置太高反而会降低性能。 - 标记清除算法缺陷,不连续内存
-
G1垃圾回收器
设计用来替换掉CMS回收器
特点:- 并行与并发: G1能充分利用多CPU多核的硬件优势, 压缩STW的时间。
- 分代收集
- 空间整合: G1从整体上看是基于标记-整理算法的, 从局部看是基于复制算法的。 运行期间不会产生内存碎片。
- 可预测的停顿
内存分配
- 新生对象主要分配在新生代Eden区
- 大对象(长字符串、长数组)直接进入老年代,-XX:PretenureSizeThrreshold参数, 用于界定大对象。
- 长期存活的对象进入老年代。
新生代对象经过一次垃圾回收, 年龄+1, 当年龄到了15, 就会被晋升到老年代。年龄存放在对象的头部内存空间。可以设置年龄阈值。 - 动态对象年龄判断
如果在Survivor区中相同年龄所有对象的大小总和,大于Survivor区的一半, 则年龄大于货真等于此年龄的对象就可以直接进入老年代。 - 空间分配担保
JVM性能监控及故障处理工具
jps
可以列出正在运行的虚拟机进程。
参数:
-q: 仅输出lvm id
-m: 输出虚拟机进程启动时传给main函数的参数
-l: 输出类的全名, 如果进程是执行的jar, 则输出jar的路径
-v: 输出虚拟机进程启动时的JVM参数
jstat
用于监视虚拟机各种运行状态信息的命令行工具。 可以显示本地或者远程虚拟机进程中的装载、内存、垃圾回收等信息。 没有GUI。 是运行期间定位虚拟机性能问题的首选。
详情可自行搜索。 例如jstat命令详解.
jinfo
实时查看和调整虚拟机各项参数。
jmap
内存映像工具。 用于生成堆转储快照(heapdump或者dump文件, 通过jvm参数 -XX:HeapDumpOnOutOfMemoryError也可以)。还可以查询finalize执行队列、java堆和永久代详细信息,
如空间使用率、当前使用的那种收集器等。
> jmap -dump:format=b,file=test.bin 19004
Dumping heap to /root/test.bin ...
Heap dump file created
jhat
虚拟机转储快照分析工具。 与jmap搭配使用。 jhat内置了一个http/html服务器,生成dump文件的分析结果以后,可以再浏览器查看。 不过jhat命令分析的结果相对简陋。
可以用visual vm、eclipse memory analyzer、 IBM HeapAnalyzer等工具。
jstack
堆栈跟踪工具。 用于生成虚拟机当前时刻线程快照。线程快照就是当前虚拟机内没一条线程正在执行的方法堆栈的集合, 生成快照的主要目的在于定位线程出现长时间停顿的原因。
如线程死锁、死循环、请求外部资源导致的长时间等待都是导致线程长时间停顿的常见原因。
HSDIS: JIT生成代码反汇编
jconsole
jvisual vm
类加载器
编译优化
公共子表达式消除
如果一个表达式E已经计算过了, 并且从先前计算到现在E中所有变量的值都没有发生变化, 那么E的这次出现就成为了公共子表达式。 对于这种表达式, 没有必要花时间再次计算,
只需要用前面计算过得表达式的结果代替E就可以了。 如果这种优化仅限于程序的基本块内, 则成为局部公共子表达式消除。 如果优化范围涵盖了多个基本块, 那么就称为
全局公共子表达式消除。
数组边界检查消除
对于java中的数组, 如果我们访问一个超过边界的下标, 会得到一个ArrayIndexOutOfBoundsException. jvm为了避免每次我们访问数组元素时都去检查下标是否越界,
会进行一些优化。 比如如果是使用常量访问数组, 那么编译期间就可以确定是否越界。 如果是循环数组,使用循环变量访问数组, 那么编译期间只需要通过数据流分析
就可以判定循环变量的取值范围是否越界, 就可以把循环体中判断越界的检查消除。
方法内联
逃逸分析
逃逸分析的基本行为就是分析对象动态作用域: 当一个对象在方法中被定义后, 他可能被外部方法引用,例如作为调用参数传递到其他方法中, 成为方法逃逸。 甚至还有可能被
外部线程访问到, 成为线程逃逸。
锁消除、标量替换 这种优化技术, 就用到了逃逸分析。
Java内存模型
java内存模型的主要目标是定义程序中各个变量的访问规则, 即在虚拟机中将变量存储到内存和从内存中去处变量这样的细节。
此处的变量, 指的是实例字段、静态字段、构成数组对象的元素等。但是不包括局部变量与方法参数, 因为后者是线程私有的不会被共享, 所以不会存在竞争问题。
主内存和工作内存
java内存模型规定了所有的变量都存储在主内存中(jvm里面的一块内存区域), 每条线程还有自己的工作内存。工作内存保存了被该线程使用到的变量的主内存副本拷贝。
线程对变量的读写必须再工作内存进行, 而不能直接读写主内存中的变量。 不同线程也无法直接访问对方工作内存中的变量, 线程间变量值的传递要经过主内存。
内存间交互操作
关于主内存与工作内存之间具体的交互协议(一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的细节), java内存模型定义了8中操作来完成,
每种操作都是原子的、不可再分的。
- lock: 作用于主内存变量,把一个变量标志为一条线程独占的状态
- unlock: 作用于主内存变量, 把一个处于锁定状态的变量释放出来
- read: 作用于主内存变量, 把变量值从主内存传输到线程的工作内存
- load: 作用于工作内存的变量, 把read操作读出来的值放入到工作内存的变量副本中
- use: 作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎, 每当jvm遇到一个需要使用变量值的字节码指令时会执行这个操作
- assign: 作用于工作内存, 每当jvm遇到一个个变量赋值的字节码指令的时候执行这个操作
- store: 作用于工作内存变量, 把工作内存的值传送到主内存, 以便随后的write操作使用
- write: 作用于主内存的变量,把store操作从工作内存得到的值回写到主内存
如果要把值从主内存读取到工作内存, 那么就要顺序执行read和load操作。 如果要写回主内存,要顺序执行store和write操作。 注意, jvm只规定了要顺序执行,但是没有规定
要连续执行, 所以在read和load之间可以执行其他指令的。 除此之外, jvm还规定了一下8中规则:
- 不允许read和load、store和write操作之一单独出现, 即不允许一个变量从主内存读取了但是工作内存不接受,或者从工作内存发起了回写但是主内存不接受的情况
- 不允许一个线程丢弃它最近的assign操作, 即变量在工作内存中改变了之后必须同步回主内存
- 不允许一个线程无原因的(没有发生过过任何assign操作)把数据从线程的工作内存同步回主内存中
- 一个新的变量只能在主内存诞生, 不允许工作内存直接使用一个未被初始化(load或者assign)的变量, 换句话说, 就是读一个变量使用use、store之前,
必须先经过了assign和load操作 - 一个变量同一时刻只能被一条线程进行lock操作, 但是lock操作可以被同一条线程执行多次, 多次执行lock之后, 只有执行相同次数的unlock操作, 变量才会解锁
- 如果对一个变量执行lock操作, 那么会清空工作内存中此变量的值, 在执行引擎使用这个变量前, 需要重新执行load或assign操作初始化变量值。
- 如果一个变量事先没有被lock锁定, 那么就不允许对他执行unlock
- 对一个变量执行unlock之前, 必须先把此变量同步回主内存
volatile型变量的特殊规则
当变量被volatile关键字修饰之后, 它具备两种特性,第一是对其他线程的可见性, 当一个线程修改了这个值, 其他线程能立刻得知这个最新值。 第二就是禁止指令重排序优化。
关于指令重排序, 可以自行搜索。
关于double、long型变量的特殊规则
java内存模型要求lock、unlock、read、load、use、assign、store、write这8个操作都是原子性, 对视对于64位的数据类型long和double, 有一条宽松的规定:允许jvm将没有被
volatile修饰的64位数据的读写操作分为2次32位的读写操作来进行, 即jvm可以不保证long和double的load、store、read、write这4个操作的原子性。 这就是double和long的
非原子性协定。
原子性、可见性、 有序性
- 原子性: 由于read、load、assign、use、store、write是原子性的,我们可以认为基本数据类型的访问读写都是原子性的。 如果要更大范围内保证原子性,还有lock和unlock指令。
- 可见性: 当一个线程修改了共享变量的值, 其他线程能够立即的值这个修改。volatile关键字可以实现。其他synchronize和final也可以实现。
- 有序性: 如果在本线程内观察,所有操作都是有序的, 如果在一个线程中观察另一个线程,则所有的操作都是无须的。 前半句指 线程内表现为串行的语义, 后半句指
指令重排序和工作内存与主内存同步延迟现象。
先行发生原则
- 程序次序规则: 在一个线程内, 按照程序代码顺序, 写在前面的操作先行发生于写在后面的操作。 准确的说,是控制流顺序而不是程序代码顺序,因为要考虑分支循环结构。
- 管程锁定规则: 一个unlock操作先行发生于后面对同一个锁的lock操作
- volatile规则: 对volatile变量的写操作先行发生于后面对这个变量的读操作
- 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作
- 线程终止规则: 线程中所有的操作都先行发生于此线程的终止检测,例如join()、isAlive()等手段检测线程已经停止运行
- 线程中断原则: 对线程interrupt()的调用先行发生于被中断线程的代码检测到中断事件的发生
- 对象终结规则: 一个对象初始化完成先行发生于他的finalize方法的开始
- 传递性: A操作先行发生于B、 B先行发生于C、则A先行发生于C
线程
各个线程共享进程资源(内存、文件IO等), 又可以独立调度(线程是CPU调度的基本单位)
锁优化
- 自旋锁与自适应自旋锁
- 锁消除
- 锁粗化
- 轻量级锁
- 偏向锁
Q.E.D.