内存是什么?
基础:Java内存结构
JVM内存分区
程序计数器是一块线程私有的内存区域,它是当前线程所执行的字节码的行号指示器。简单来说,它记录的是当前线程正在执行的虚拟机字节码指令(如果是 Native 方法,该值为空)。
一般我们很少关心这个区域
Java 虚拟机栈是一块线程私有的内存区域,它总是和某个线程关联在一起,每当创建一个线程时, JVM 就会为其创建一个对应的 Java 虚拟机栈,用于存储 Java 方法执行时用到的局部变量表、操作数栈、动态链接、方法出口等信息。
一般我们也不怎么需要关心这个区域
本地方法栈是为 JVM 运行 Native 方法使用的空间,它也是线程私有的内存区域,它的作用与上一小节的Java虚拟机栈的作用是类似的。除了代码中包含的常规的 Native 方法会使用这个存储空间,在 JVM 利用 JIT 技术时会将一些 Java 方法重新编译为 NativeCode 代码,这些编译后的本地方法代码也是利用这个栈来跟踪方法的执行状态。
这也是一个不怎么需要关注的区域
JVM 管理内存中最大的一块,也是 JVM 中最最最核心的储存区域,被所有线程所共享。我们在 Java 中创建的对象实例就储存在这里,堆区也是 GC 主要发生的地区。
这是我们最核心关注的内存区域
用于储存类信息、常量、静态变量等可以被多个对象实例共享的数据,这块区域储存的信息相对稳定,因此很少发生 GC 。在 GC 机制中称其为“永生区”( Perm,Java 8 之后改称元空间 Meta Space )。
由于方法区的内存很难被 GC ,因此如果使用不当,很有可能导致内存过载。
这是一块常常被忽略,但却很重要的内存区域。
堆外内存不是由 JVM 管理的内存,但它也是 Java 中非常重要的一种内存使用方式, NIO 等包中都频繁地使用了堆外内存来实现“零拷贝”的效果(在网络 IO 处理中,如果需要传输储存在 JVM 内存区域中的对象,需要先将它们拷贝到堆外内存再进行传递,会造成额外的空间和性能浪费),主要通过 ByteBuffer 和 Unsafe 两种方式来进行分配和使用。
但是在使用时一定要注意,堆外内存是完全不受 GC 控制的,也就是说和 C++ 一样,需要我们手动去分配和回收内存。
Java 对象的内存结构
对象的 Mark Word 部分占4个字节,其内容是一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。
指向对象所属的 Class 对象,也是占用 4 个字节( 32 位JVM)。
包括对象的所有成员变量(注意 static 变量并不包含在内,因为它是属于 class 的),其大小由具体的成员变量大小决定,如 byte 和 boolean 是一个字节,int 和 float 是 4 个字节,对象的引用则是 4 个字节( 32 位 JVM )。
为了对齐 8 个字节而增设的填充区域,这是为了提升 CPU 读取内存的效率,详细请看:什么是字节对齐,为什么需要字节对齐?
public class Int {
public int val;
}
议题:如何计算一个对象占用的内存大小?
实验:对象的实际内存布局
class Pojo {
public int a;
public String b;
public int c;
public boolean d;
private long e; // e设置为私有的,后面讲解为什么
public Object f;
Pojo() { e = 1024L;}
}
使用 jol 工具查看其内存布局如下:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Pojo.a N/A
16 8 long Pojo.e N/A
24 4 int Pojo.c N/A
28 1 boolean Pojo.d N/A
29 3 (alignment/padding gap)
32 4 java.lang.String Pojo.b N/A
36 4 java.lang.Object Pojo.f
这里由于我的本地环境开启了对象头压缩,因此对象头所占用的大小为(4+8)=12字节。从这个内存布局表上不难看出,成员变量在实际分配内存时,并不是按照声明的顺序来储存的,此外在变量 d 之后,还出现了一块用于对齐内存的 padding gap ,这说明计算对象实际数据所占用的内存大小时,并不是简单的求和就可以的。
考虑到这些细节问题,我们需要一些更有力的工具来帮助我们精确的计算。
Unsafe & 变量偏移地址
在上面的内存布局表中,可以看到 OFFSET 一列,这便是对应变量的偏移地址,如果你了解 C/C++ 中的指针,那这个概念就很好理解,它其实是告诉了 CPU 要从什么位置取出对应的数据。举个例子,假设 Pojo 类的一个对象p存放在以 0x0010 开始的内存空间中,我们需要获取它的成员变量 b ,由于其偏移地址是 32(转换成十六进制为20),占用大小是 4 ,那么实际储存变量b的内存空间就是 0 x0030 ~ 0x0033 ,根据这个 CPU 就可以很容易地获取到变量了。
实际上在反射中,正是通过这样的方式来获取指定属性值的,具体实现上则需要借助强大的 Unsafe 工具。 Unsafe 在 Java 的世界中可谓是一个“神龙不见首”的存在,借助它你可以操作系统底层,实现许多不可意思的操作(比如修改变量的可见性,分配和回收堆外内存等),用起来简直像在写 C++ 。不过也正因为其功能的强大性,随意使用极有可能引发程序崩溃,因此官方不建议在除系统实现(如反射等)以外的场景使用,网上也很难找到Unsafe的详细使用指南(一些参考资料),当然这并不影响我们揭开它的神秘面纱,接下来就看看如何通过变量偏移地址来获取一个变量。
@Test
public void testUnsafe() throws Exception {
Class<?> unsafeClass = null;
Unsafe unsafe = null;
try {
unsafeClass = Class.forName("sun.misc.Unsafe");
final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
unsafe = (Unsafe) unsafeField.get(null);
} catch (Exception e) {
// Ignore.
}
Pojo p = new Pojo();
Field f = Pojo.class.getDeclaredField("e");
long eOffset = unsafe.objectFieldOffset(f); // eOffset = 16
if (eOffset > 0L) {
long eVal = unsafe.getLong(p, eOffset);
System.out.println(eVal); // 1024
}
}
出于安全起见,一般情况下在正常的代码中是无法直接获取 Unsafe 的实例的,这里我们通过反射的方式hack了一把来拿到 unsafe 实例。接着通过调用 objectFieldOffset 方法获取到成员变量 e 的地址偏移为 16(和 jol 中的结果一致),最终我们通过 getLong() 方法,传入 e 的地址偏移量,便获取到了 e 的值。可以看到尽管 Pojo 类中 e 是一个私有属性,通过这种方法依然是可以获取到它的值的。
有了 objectFieldOffset 这个工具,我们就可以通过代码精确的计算一个对象在内存中所占用的空间大小了,代码如下(参考自 apache luence )
计算 shallowSize
public long shallowSizeOf(Object o) {
Clazz<?> c = o.getClass(); // 对应的类
// 初始大小:对象头
long shallowInstanceSize = NUM_BYTES_OBJECT_HEADER;
for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
// 需要循环获取对象所继承的所有类以遍历其包含的所有成员变量
final Field[] fields = c.getDeclaredFields();
for (final Field f : fields) {
// 注意,f的遍历顺序是按照声明顺序,而不是实际储存顺序
if (!Modifier.isStatic(f.getModifiers())) {
// 静态变量不用考虑
final Class<?> type = f.getType();
// 成员变量占用的空间,如果是基本类型(int,long等),直接是其所占空间,否则就是当前JRE环境下引用的大小
final int fsize = type.isPrimitive() ? primitiveSizes.get(type) : NUM_BYTES_OBJECT_REF;
// 通过unsafe方法获取当前变量的偏移地址,并加上成员变量的大小,得到最终成员变量的偏移地址结束值(注意不是开始值)
final long offsetPlusSize =
((Number) objectFieldOffsetMethod.invoke(theUnsafe, f)).longValue() + fsize;
// 因为储存顺序和遍历顺序不一致,所以不能直接相加,直接取最大值即可,最终循环结束完得到的一定是最后一个成员变量的偏移地址结束值,也就是所有成员变量的总大小
shallowInstanceSize = Math.max(shallowInstanceSize, offsetPlusSize);
}
}
}
// 最后进行内存对齐,NUM_BYTES_OBJECT_ALIGNMENT是需要对齐的位数(一般是8)
shallowInstanceSize += (long) NUM_BYTES_OBJECT_ALIGNMENT - 1L;
return shallowInstanceSize - (shallowInstanceSize % NUM_BYTES_OBJECT_ALIGNMENT);
}
基础:JVM GC
可回收对象的标记
方法中局部变量区中的对象引用
Java 操作栈中对象引用
常量池中的对象引用
本地方法栈中的对象引用
类的 Class 对象
垃圾收集算法
扫描一遍所有对象,并标记哪些可回收,然后清除,缺点是回收完会产生很多碎片空间,而且整体效率不高。
将内存划分为相等的两块,每次只使用其中一块。当这一块内存用完时,就将还存活的对象复制到另一块上面,然后将已经使用过的内存空间一次清理掉。缺点是对内存空间消耗较大(实际只用了一半),并且当对象存活概率较高的时候,复制带来的额外开销也很高。
将原有标记-清除算法进行改造,不是直接对可回收对象进行清理,而是让所有存活对象都向另一端移动,然后直接清理掉端边界以外的内存。
对象分代
储存被创建没多久的对象,具体又分为 Eden 和 Survivor 两个区域。所有对象刚被创建时都存在 Eden 区,当 Eden 区满后会触发一次 GC ,并将剩余存活的对象转移到 Survivor 区。为了采用复制法,会有两个大小相同的 Survivor 区,并且始终有一个是空的。
新生代发生的 GC 被称为 Young GC 或 Minor GC,是发生频率最高的一种 GC 。
存放 Young 区 Survivor 满后触发 minor GC 后仍然存活的对象,当 Eden 区满后会将存活的对象放入 Survivor 区域,如果 Survivor 区存不下这些对象, GC 收集器就会将这些对象直接存放到 Old 区中,如果 Survivor 区中的对象足够老,也直接存放到 Old 区中。
如果 Old 区满了,将会触发 Major GC 回收老年代空间。
主要存放类的 Class 对象和常量,以及静态变量,这块内存不属于堆区,而是属于方法区。 Perm 区的 GC 条件非常苛刻,以一个类的回收为例,需要同时满足以下条件才能够将其回收:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
加载该类的 ClassLoader 已经被回收;
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
GC 时间:由于 GC 是 STW 的,所以在 GC 时间内整个应用是处于暂停状态的。
GC 频率:单位时间内 GC 发生的次数。
比如某交易系统,要求所有的请求在 1000ms 内得到响应。假设 GC 的时间占比不超过总运行时间的 10% ,那就要求 GC 时间都不能超过 100ms 。
仍然以上面的交易系统为例,要求每分钟至少可以处理 1000 个订单,并且 GC 的时间占比不能超过总运行时间的 10% ,那就意味着每分钟的 GC 时间总和不能超过 6s 。假设单次 GC 的耗时为 50ms ,进一步转换即可得到对 GC 频率的要求为每分钟不超 120 次。
因为每分钟需要完成 1000 次操作,那就意味着平均每9次操作可以触发一次 GC ,这就进一步转换成了对局部变量产生速率的要求。
一般来说是硬件指标,比如某系统要求必须能够部署在 2 核 4G 的服务器实例上,且能够满足延迟和吞吐量的需要。结合具体的硬件指标和 JVM 特性可以进一步估算得到对 GC 的要求。
议题:如何回收对象占用的内存?
清除引用
public void refTest() {
Pojo p = new Pojo();
// ... do something with p
// help the gc (?)
p = null;
}
尽量使用局部变量
尽量少用静态变量
System.gc() ?
Calling this method suggests that the Java virtual machine expend
effort toward recycling unused objects in order to make the memory
they currently occupy available for quick reuse. When control
returns from the method call, the virtual machine has made
its best effort to recycle all discarded objects.
结论
Java的内存是被自动管理的
无法通过手动的方式回收内存,因为这违反了Java语言设计的初衷 可以通过减少变量作用域等方式帮助GC更好地工作