读书笔记之《实战Java虚拟机》(7):分析 Java 堆

211 阅读4分钟

内存溢出

内存溢出,OutOfMemory,简称 OOM,通常出现在某一块内存空间耗尽的时候。

堆溢出

示例如下:

import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[1024 * 1024]);
        }
    }
}

这个 list 对象总是持有 byte 数组的强引用,导致 byte 数据无法回收。运行以上代码,会立刻抛出错误:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at Main.main(Main.java:9)

直接内存溢出

示例如下:

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        while (true) {
            list.add(ByteBuffer.allocateDirect(1024 * 1024));
        }
    }
}

不停申请直接内存,list 对象持有直接内存的强引用,gc 时不会被回收,运行会抛出错误:

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:694)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at Main.main(Main.java:10)

默认下最大直接内存(-XX:MaxDirectMemorySize)等于最大堆大小(-Xmx)。

过多线程溢出

示例如下:

public class Main {

    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                try {
                    Thread.sleep(10_000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

每个线程的开启都要占用系统内存,因此创建线程数量太多,可能导致 OOM:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
	at java.lang.Thread.start0(Native Method)
	at java.lang.Thread.start(Thread.java:717)
	at Main.main(Main.java:15)

永久区溢出

由于笔者 JDK 1.8 缘故,这里演示元数据区溢出,本质一样。结合第三方依赖 cglib 无限产生类:

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;

public class Main {

    public static void main(String[] args) {
        int count = 0;
        try {
            while (true) {
                Enhancer enhancer = new Enhancer();
                // 指定不适用缓存
                enhancer.setUseCache(false);
                enhancer.setSuperclass(Main.class);
                enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> null);
                enhancer.create();
                count++;
            }
        } catch (Exception e) {
            System.out.println(count);
            e.printStackTrace();
        }
    }
}

运行一段时间,抛出异常:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.lang.Class.forName0(Native Method)
	at java.lang.Class.forName(Class.java:348)
	at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:467)
	at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:339)
	at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
	at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:117)
	at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:294)
	at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
	at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
	at Main.main(Main.java:15)

GC 效率低下

虚拟机会检查下列情况:

  • GC 耗时是否超过 98%
  • 老年代释放是否小于 2%
  • eden 区释放是否小于 2%
  • 是否连续最近 5 次同时出现上述情况

如果满足上述条件,虚拟机会抛出异常:

java.lang.OutOfMemoryError: GC overhead limit exceeded#

这个 OOM 只是辅助作用,可以通过 -XX:-UseGCOverheadLimit 禁止产生。

String 在虚拟机中的实现

String 对象的特点

不变性

String 对象一旦生成,则不能再对它进行改变。当一个对象需要多线程共享时,省略同步和锁等待的时间,大幅提高系统性能。

String.substring()、String.concat() 方法并没有修改原始字符串,而是产生了一个新的字符串。如果需要一个可修改的字符串,可以使用 StringBuffer 或者 StringBuilder 对象。

针对常量池的优化

当两个 String 对象拥有相同的值时,它们只因用常量池中的同一个拷贝,节省内存空间。

String str1 = new String("abc");
String str2 = new String("abc");
System.out.println(str1 == str2);                    // false
System.out.println(str1 == str2.intern());           // false
System.out.println("abc" == str2.intern());          // true
System.out.println(str1.intern() == str2.intern());  // true

String.intern() 方法返回字符串在常量池中的引用。

类的 final 定义

作为 final 类的 String 对象不可能有任何子类,主要是为了“安全性”和“效率”。

有关 String 的内存泄漏

尽管垃圾回收器已经将内存泄漏的概率大大降低,但是并不意味着没有内存泄漏的可能。

JDK 1.6 版本中,String 对象内部结构如下:

简单翻阅下 JDK 1.6 中 String.substring() 的实现:

String(int offset, int count, char value[]) {
    this.value = value;
    this.offset = offset;
    this.count = count;
}

public String substring(int beginIndex, int endIndex) {
    ...
    return  new String(offset + beginIndex, endIndex - beginIndex, value);
}

如果使用 String.substring() 讲一个大字符串切割为小字符串,当大字符串被回收时,小字符串 value 中多余的部分无法被回收,造成内存泄露。

JDK 1.7 大幅调整了 String 的实现,去掉了 offset 和 count,substring 方法不再复用原 String 的 value,而是复制产生新的字符串,避免了内存泄露的发生。

public String(char value[], int offset, int count) {
    ...
    this.value = Arrays.copyOfRange(value, offset, offset + count);
}

public String substring(int beginIndex, int endIndex) {
    ...
    int subLen = endIndex - beginIndex;
    return new String(value, beginIndex, subLen);
}

内存泄露,指由于疏忽或者错误未能释放不再使用的内存空间。

有关 String 常量池的位置

虚拟机中,有块称为常量池的区间专门用于存放字符串常量。JDK 1.6 之前,这块区间属于永久区的一部分,JDK 1.7 之后,移到了堆中进行管理。

import java.util.ArrayList;
import java.util.List;

/**
 * @author caojiantao
 */
public class Main {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        while (true){
            int i = 0;
            list.add(String.valueOf(i++).intern());
        }
    }
}

String.intern() 方法获得在常量池中的字符串引用,如果常量池中没有该常量字符串,该方法会将字符串加入常量池。

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:2245)
	at java.util.Arrays.copyOf(Arrays.java:2219)
	at java.util.ArrayList.grow(ArrayList.java:242)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
	at java.util.ArrayList.add(ArrayList.java:440)
	at Main.main(Main.java:13)

虽然 String.intern() 的返回值永远等于该字符串常量,但并不代表相同的字符串 intern() 的返回值都是一样。如果一个字符串在 intern() 调用之后被回收,再此进行一次 intern() 调用,那么该字符串重新被加入常量池,但是引用位置已经不同。

MAT 分析 Java 堆

有点复杂 (・ω・`ll)