内存划分区域/运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会将管理的内存划分为若干个不同的数据区域。
运行时数据区域的划分情况
JDK1.8 之前,运行时数据区域划分为:
堆、方法区、程序计数器、本地方法栈、虚拟机栈。

JDK1.8 开始,运行时数据区域划分为:
堆、程序计数器、本地方法栈、虚拟机栈。
方法区挪到了本地内存的元空间中,方法区中的运行时常量池挪到了堆中。

程序计数器
概念:程序计数器(PC)是当前线程所执行的字节码的行号指示器,字节码解释器工作时通过改变 PC 的值来选取下一条要执行的字节码指令。当调用 native 方法时,PC 存放 undefined。
线程独立:为了线程切换后能够回到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的 PC 彼此互不影响,独立存储。
作用:1-改变 PC 来依次读取指令,从而实现流程控制;2-记录线程执行位置,当多线程情况时,在线程切换后,当前线程可以知道上次运行的位置。
错误:不会出现 OutOfMemoryError。
虚拟机栈
概念:记录 Java 方法执行的内存模型,每次方法调用通过栈传递。当进行函数调用,将对应的栈帧压入虚拟机栈;当函数调用结束,将虚拟机栈顶栈帧 pop 出。
调用结束:直接 return,抛出异常。
存储:Java 虚拟机栈由一个个栈帧组成,每个栈帧保存了局部变量表、操作数栈、动态链接、方法出口信息。
局部变量表:存放了基本数据类型和对象引用。
错误:会出现 StackOverFlowError 和 OutOfMemoryError。
StackOverFlowError:若虚拟机栈不允许动态扩展时,当线程请求栈的深度超过虚拟机最大深度时,抛出 StackOverError 错误。OutOfMemoryError:若虚拟机栈允许动态扩展时,当动态扩展时栈无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
本地方法栈
概念:虚拟机使用到的 Native 方法服务。和虚拟机栈类似,栈帧压入与弹出。
存储:同样,每个栈帧保存了局部变量表、操作数栈、动态链接、方法出口信息。
错误:会出现 StackOverFlowError 和 OutOfMemoryError。
程序计数器、虚拟机栈、本地方法栈是线程私有的,和线程的生命周期相同,随线程创建而创建,随线程消亡而消亡。
程序计数器私有在于其 2 点作用,栈私有在于局部变量不被访问&栈存着线程运行的栈帧。
堆
概念:Java 虚拟机管理内存中最大一块内存,线程共享,虚拟机启动时创建。
内存分配:堆的唯一目的是存放对象实例,几乎所有的对象实例和数组都在这里分配内存。
JDK1.7 开始默认开启逃逸分析,若某些方法的对象引用未被返回或未被方法外访问,则对象直接在栈上分配内存。
堆内存分配:JDK1.8 前,堆内存分为新生代、老生代和永生代。JDK1.8 开始,方法区(HotSpot 的永生代)被彻底移除,取而代之的是元空间,元空间使用直接内存。
错误:会出现 OutOfMemoryError。
OutOfMemoryError: GC Overhead Limit Exceeded当 JVM 花费太多时间执行垃圾回收,但是只回收到很少的堆空间。OutOfMemoryError: Jave Heap space堆内存空间不足以存放新创建对象时。
方法区
概念:用于存储已被虚拟机加载的类信息、常量、静态变量等数据。HotSpot 虚拟机提供了方法区的一种实现方式-永久代。
移除:JDK1.8 开始,方法区被彻底移除,取而代之的是元空间,元空间使用的是直接内存。
永久代更换为元空间的原因:
- 永久代的大小受限于 JVM 本身的大小上线;元空间使用直接内存,受限于本机内存限制。元空间溢出的概率比永久代溢出的概率要小。
- 元空间存放类的元数据,加载多少类的元数据由系统实际可用空间控制,能够加载的类变多。
运行时常量池
概念:运行时常量池属于方法区的一部分,Class 文件中除了由类的版本、字段、方法、接口等信息外,还有常量池表。
位置:JDK1.7 时,字符串常量池被拿到了堆中,剩余部分仍存在于方法区中,JDK1.8 中更换为元空间。
错误:会出现 OutOfMemoryError。
直接内存
概念:直接内存并不是虚拟机运行时数据的一部分,但也被经常使用,也会出现 OutOfMemoryError。直接内存的分配不受 Java 堆的限制,但是受限于本机总内存大小和处理器寻址空间的限制。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
Java 内存补充
heap 和 stack 的区别
(1) 申请方式
stack: 由系统自动分配。例如,声明在函数中一个局部变量 int b; 系统自动在栈中为 b 开辟空间
heap: 需要程序员自己申请,并指明大小,在 c 中 malloc 函数,对于 Java 需要手动 new Object () 的形式开辟
(2) 申请后系统的响应
stack: 只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
heap: 首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
(3) 申请大小的限制
stack: 栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS 下,栈的大小是 2M(默认值也取决于虚拟内存的大小),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。
heap: 堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
(4) 申请效率的比较
stack: 由系统自动分配,速度较快。但程序员是无法控制的。
heap: 由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片, 不过用起来最方便。
(5) heap 和 stack 中的存储内容
stack: 在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句) 的地址,然后是函数的各个参数,在大多数的 C 编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
heap: 一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。
OOM 与 Memory Leak
内存溢出(out of memory):简单地说内存溢出就是指程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。
内存泄漏(memory leak):内存泄漏指程序运行过程中分配内存给临时变量,用完之后却没有被 GC 回收或者无法被 GC 回收,始终占用着内存,既不能被使用也不能分配给其他程序,于是就发生了内存泄漏。
memory leak 最终会导致 out of memory!
内存泄漏:
- 静态集合类引起的内存泄漏。HashMap、Vector 等所引用的对象不能被释放。
下面这种只能是释放引用,但是不能释放这个对象。
Vector<Object> v=new Vector<Object>(100);
Object o = new Object();
v.add(o);
o = null;- HashSet 等,对可变对象 key 进行修改。
如果一个 Object 已经加入 HashSet 后,对这个 Object 进行了修改。那么这个 Object 将无法删除。因为对象的 hashcode 发生了变化,与当前的索引位置不匹配。
set.add(p3);
p3. setAge(2); //修改 p3 的年龄, 此时 p3 元素对应的 hashcode 值发生改变
set. remove(p3); //此时 remove 不掉,造成内存泄漏
set. add(p3); //重新添加,可以添加成功- 监听器没有释放
- 各种连接,如数据库连接、文件连接、网络连接等。
- 单例模式持有外部对象的引用,则无法释放。
避免内存泄漏的几点建议:
1、尽早释放无用对象的引用。
2、避免在循环中创建对象。
3、使用字符串处理时避免使用 String,应使用 StringBuffer。
4、尽量少使用静态变量,因为静态变量存放在永久代,基本不参与垃圾回收。
OOM 的认识、排查方法
StackOverflowError
原因:
- Java 虚拟机栈有一定深度,如果直接或间接递归调用自己,那么就会出现 StackOverflowError。
- 执行了大量的方法,导致线程栈空间耗尽
- 由于栈帧会存储局部变量,若方法中声明了海量的变量,也会 OOM
- native 代码会要求栈上保留一定的空间
解决:
- 可以通过查看程序抛出的异常堆栈,找到重复的代码,进而找到递归调用的方法;
- 类之间存在循环依赖的可能,例如两个类相互引用,并执行了 toString 方法;
-Xss在启动时增加线程的内存空间,避免大量变量 or native 代码要求的栈空间太大
OOM:Java heap space
原因:
- 创建了超大的对象,一般是数组
- 数据访问量徒增,例如秒杀活动,结合业务指标查看是否有尖峰
- 过度使用 Finalizer,导致对象没有被 GC
- 内存泄漏,大量对象没有被释放,如 File 等资源没有回收。
解决:
-Xmx增加堆空间大小- 超大对象,检查是否需要这么多数据;如一次获得数据库全部数据
- 业务峰值压力,则限流降级或增加机器资源
- 内存泄漏,找到持有对象,修改相关逻辑
OOM:GC overhead limit exceeded
原因:
- 程序花费了大量的时间进行 GC,但是只回收了少量内存,且持续 5 次,则抛出该异常
- 补充:不过,有可能 Java heap space 会先于该异常出现。
解决:
- 添加 JVM 参数-XX:-UseGCOverheadLimit
- 检查是否有大量死循环代码或者大内存代码
- dump 内存分析,检查是否存在内存泄漏,如果没有,加大内存
OOM:Direct buffer memory
未整理完:
java 9 种常见的 OOM 场景——原因分析及解决方案_qq60b9df8289699 的技术博客_51CTO 博客
排查 OOM 的方法:
- 增加两个参数-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump. hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录;
- 同时 jstat 查看监控 JVM 的内存和 GC 情况,先观察问题大概出在什么区域;
- 使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以把改为弱引用。
Java 内存模型
Java 内存模型(下文简称 JMM)就是在底层处理器内存模型的基础上,定义自己的多线程语义。它明确指定了一组排序规则,来保证线程间的可见性。
这一组规则被称为 Happens-Before, JMM 规定,要想保证 B 操作能够看到 A 操作的结果(无论它们是否在同一个线程),那么 A 和 B 之间必须满足 Happens-Before 关系:
- 单线程规则:一个线程中的每个动作都 happens-before 该线程中后续的每个动作
- 监视器锁定规则:监听器的解锁动作 happens-before 后续对这个监听器的锁定动作
- volatile 变量规则:对 volatile 字段的写入动作 happens-before 后续对这个字段的每个读取动作
- 线程 start 规则:线程 start () 方法的执行 happens-before 一个启动线程内的任意动作
- 线程 join 规则:一个线程内的所有动作 happens-before 任意其他线程在该线程 join () 成功返回之前
- 传递性:如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C
怎么理解 happens-before 呢?如果按字面意思,比如第二个规则,线程(不管是不是同一个)的解锁动作发生在锁定之前?这明显不对。happens-before 也是为了保证可见性,比如那个解锁和加锁的动作,可以这样理解,线程 1 释放锁退出同步块,线程 2 加锁进入同步块,那么线程 2 就能看见线程 1 对共享对象修改的结果。
Java 提供了几种语言结构,包括 volatile, final 和 synchronized, 它们旨在帮助程序员向编译器描述程序的并发要求,其中:
- volatile - 保证可见性和有序性
- synchronized - 保证可见性和有序性; 通过*管程(Monitor)*保证一组动作的**原子性__
- final - 通过禁止在构造函数初始化和给 final 字段赋值这两个动作的重排序,保证可见性(如果 this 引用逃逸就不好说可见性了)
编译器在遇到这些关键字时,会插入相应的内存屏障,保证语义的正确性。
有一点需要注意的是,synchronized 不保证同步块内的代码禁止重排序,因为它通过锁保证同一时刻只有一个线程访问同步块(或临界区),也就是说同步块的代码只需满足 as-if-serial 语义 - 只要单线程的执行结果不改变,可以进行重排序。
所以说,Java 内存模型描述的是多线程对共享内存修改后彼此之间的可见性,另外,还确保正确同步的 Java 代码可以在不同体系结构的处理器上正确运行。
HotSpot 虚拟机中的对象
对象的创建
第一步:类加载检查
虚拟机遇到一条创建对象指令,首先检查这个指令的参数能否在常量池中定位到类的符号引用,并检查这个符号引用代表的类是否被加载过、解析和初始化过。如果没有,则先执行类加载过程。
第二部:分配内存
对象需要分配的内存大小在类加载完成后便可以确定,分配对象的内存就是将一块确定大小的内存从 Java 堆中划分出来。分配方法有”指针碰撞”和”空闲列表”两种。
内存分配方式
内存分配方式由 Java 堆是否规整决定,Java 堆是否规整由垃圾回收是否带有压缩整理功能决定。
“指针碰撞”-适用于堆规整/没有内存碎片的情况下,用过的内存和没用过的内存各放到一侧,保留一个分界线指针,分配时移动分界线指针。GC-Seriel、ParNew。
“空闲列表”-适用于堆内存不规整情况,虚拟机维护一个空闲内存列表,分配时将足够大的内存分配给该对象。GC-CMS内存分配并发问题
创建对象是非常频繁的,因此需要保证线程安全。主要两种方式,CAS 机制+失败重试,TLAB。
CAS 机制+失败重试:乐观锁 CAS 机制,失败后就重新尝试,保证操作的原子性。
TLAB:为每个线程在 Eden 区预分配一块内存,当对象大于 TLAB 剩余内存或内存耗尽时,采用 CAS+失败重试。
第三步:初始化零值
虚拟机将分配到的内存空间都初始化为零值(不包括对象头),保证了对象的实例字段在不赋初值的情况下就可以直接使用。
第四步:设置对象头
虚拟机需要对对象进行必要的设置,例如对象属于哪个类的实例、如何找到类的元数据、对象的哈希码、对象的 GC 分代年龄等。这些信息存放在对象头中。
第五步:执行 init 方法
上述工作完成后,从虚拟机的角度,对象已经生成了,从 Java 程序的角度,还需要执行 init 方法。
对象的内存布局
HotSpot 虚拟机中,对象的内存分为 3 部分:对象头、实例数据和对其填充。
对象头:存储两部分数据,一部分存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态),另一部分是类型指针,即对象指向它的类元数据,虚拟机通过这个指针来判断这个对象是哪个类的实例。
实例数据:程序中定义的各种类型的字段内容。
对其填充:仅仅用于占位。因为 HotSpot 虚拟机的自动内存管理系统要求对象的其实地址必须是 8 字节的整数倍。
对象的访问定位
Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,主流的有使用句柄和直接指针两种方式。
使用句柄:Java 堆中会划分一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,句柄中包含了对象的实例数据和类型数据的具体地址信息。
直接地址:Java 堆中存储对象实例数据,reference 中存储的是对象实例数据的地址信息,其对象实例数据中存储对象类型数据的指针。
使用句柄时,对象被移动只会改变句柄中的实例数据指针,reference 本身不会被修改。使用指针时,访问数据更快,只需要一次指针定位。


字符串与基本数据类型的常量池
字符串常量池
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
String str6 = new String("string");
String str7 = new String("string");
final String str8 = "str";
final String str9 = "ing";
String str10 = str8 + str9;
final String str11 = getStr(); // 键盘输入
String str12 = str + str8+str11;
// 字符串常量池对象:
// str1 = str8 str2 = str9 str3=str5=str10
// 堆对象:
// str4 str6 str7 str12直接使用双引号创建 "str" 时,如果 "str" 不在字符串常量池,则 "str" 放到字符串常量池里,然后 str1 指向字符串常量池中 "str" 对象。同理,str2 、 str5 都是字符串常量池中的对象。
对于 str3 涉及到常量折叠,即将常量的表达式值作为常量嵌入到最终生成的代码中,是 Javac 编译器对源代码做的优化。即,编译器将 str3 优化为 str3="string"。因此 str3 也是字符串常量池中的对象。根据常量折叠,str10 同样也是字符串常量池中的对象。
但是,如果是运行时才能确定值,编译器无法进行优化,即 str12。
使用常量折叠情况:
1 基本数据类型和字符串常量
2 final 修饰的基本数据类型和字符串常量
3 字符串通过+拼接的字符串、基本数据类型的加减乘除、基本数据类型的位运算
引用的值在编译器是无法确定的,只有执行时确定。对象引用和+拼接方式,实际上通过 StringBuilder 调用 append 方法后调用 toString 得到的 String 对象。因此,str4 时堆上的对象。
str5 和 str6 都是堆上的对象,此时可以看成两个 String 类的实例,并不相等。
String s1 = new String(“abc”); 创建了几个字符串对象。
如果字符串常量池有”abc”,则只创建堆对象;没有,则创建两个。
使用 intern 可以将其转换为字符串常量。
基本数据类型的包装类和常量池
Java 中基本类型的包装类的大部分都实现了常量池技术。
Byte, Short, Integer, Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0, 127] 范围的缓存数据,Boolean 直接返回 True Or False。处于缓存范围内,可以使用 == 来进行比较,否则使用 equal 进行比较。
Integer i1 = 33;
Integer i2 = 33; // 相等,都指向了 IntegerCache.cache数组中相同位置
Integer i11 = 333;
Integer i22 = 333; // 不相等,不在常量池中,需要再创建
Double i3 = 1.2;
Double i4 = 1.2; // 不相等,没有常量池技术,直接创建堆对象常量池技术
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
}Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
// 正确
i1=i2 i1=i2+i3 i4=i5+i6 40=i5+i6
// 错误
i1=i4 i4=i5装箱:
当将 int 赋值给 Integer,会出现装箱。
Integer i = 40; 等价于 Integer i = Integer.valueof(40);,使用的是常量池中的对象。
Integer i = new Integer(40); 使用的是 IntegerCache 中的对象。两者并不相等。
拆箱:
当 Integer 对象与 int 数据进行比较时,会出现拆箱,将 Integer 对象转换为 int 数值后再比较。
i4 == i5 + i6 为什么是 true 呢?因为, i5 和 i6 会进行自动拆箱操作,进行数值相加,即 i4 == 40 。然后 i4 也拆箱。