从一个 JDK6 BUG 看 JAVA 数组创建

简介: 前几天在调试一段程序, 奇怪的发现: 程序性能和缓存大小是负相关的——缓存开越大,程序越慢,最快和最慢之间差 2 个数量级。开 jprofiler 查了下,发现了 JDK6 里有这样一段代码 (版本 build 1.

前几天在调试一段程序, 奇怪的发现: 程序性能和缓存大小是负相关的——缓存开越大,程序越慢,最快和最慢之间差 2 个数量级。开 jprofiler 查了下,发现了 JDK6 里有这样一段代码 (版本 build 1.6.0_14-b08):

红线里的部分只能用笔误来形容 ——Arrays.copyOf() 复制了我传递的整个缓存——在我的测试里,当缓存大小是 8KB 时,复制大约需要 3000ns,而缓存大小是 64KB 时,复制需要 21000ns.


显然,使得 Arrays.copyOf 很慢的原因是每次创建新数组,造成 CPU cache 利用率不高,下面来证明一下。

Arrays.copyOf 的执行流程需要以下三步:

1. JVM 在堆中分配 8KB 内存;
    Java 堆分配是 O(1), 顺序标记下一块 8KB 已经被分配了。
    这基本可以看作不需要时间,实测约 150ns.

2. JVM 初始化 8KB 内存为 { 0 }
    Java 会自动给新分配的数组元素置 0,即用 0 覆写新分配的内存。
    CPU 怎么执行覆写?简单来说,有两种策略: Write-Through 和 Write-Back(注1).
    策略 Write-Through 是无视 cache 直接覆写主内存 RAM,而 Write-Back 首会先从 RAM 读数据到 cache, 然后再覆写 cache, 标记 cacheline 为 dirty. 
    不管走什么策略,因为新分配的内存基本还在主存 RAM 里,覆写过程都需要访问 RAM,传输 8KB 的数据。
    假设 JVM 优化得不错,能足够利用总线带宽。则可以这么估计:我机器的内存是 DDR3 1066@533MHz, 每次可以传输 8 x 64 / 8 = 64B, 延时 13.125ns (DDR3 1066),  一共需要 1680ns. 
   如果是 Write-Through,则总延时 = 写 RAM 延时 = 1680ns.
   如果是 Write-Back,除了从 RAM 读出的时间外,还需要估算中考虑 L1 的写入时间,假设 JVM 代码足够优化,使用 64B cacheline 粒度写入:
   估计值 = 1680ns + 8KB / 64B * 2 cycle / 2.4GHz (注2)= 1787ns

    实际验证,在我的机器上需要 1900-2000ns.


    注 1:从网上查到了 Intel i5 CPU 的数据, 子型号不太匹配, 凑合一下: 
      L1 Read/Write: 2 cycles per read (throughput)
      L2->L1 B/W (Read, 64B) = 4 cycles per cache line
      L2 Write (Write, 64B) = 7 cycles per write (cache line)
      L3->L1 B/W (Read, 64B) = 6 cycles per cache line
      L3 Write (Write, 64B) = 11 cycles per write (cache line)
    下文也是用这个参数估算。

     注 2:更复杂的情况是,因为数组刚创建,访问首先会出现 cache miss,这时 CPU 会用 Write-Back(miss allocate) — 简称 Wite-Allocate 或者 Write-Back(through on miss) 策略,决定是先从下一级 cache 读出 cacheline 还是直接覆写下一级 cache.
        另外的复杂性是 CPU L1/L2/L3 cache 可能走不同的策略。

   如果 CPU 是 Write-Allocate 策略,初始化 { 0 } 完毕后,数组会全部加载到 cache. 这是 JAVA 创建数组的优势。

3. 拷贝 8KB 到新分配的内存
    System.arraycopy 是 native 调用,实现未知 (手头只能看到 OpenJDK 的代码), 优化不错的话, 应该是调用 rep movsd 或类似的指令。
    这里的复杂性在于,从源数组读可能会出现 cache-miss, 写入新数组也可能出现 cache-miss. 通常 CPU L1/L2 cache 的命中率保守是 80%,我们可以估算一下:

    如果 CPU 用 Write-Throught 策略, 这样读走 cache, 写直接写穿到 RAM, 很容易看出这样性能较低。
      读 L1  = 8KB / 64B * 2-cycles / 2.4GHz = 107ns
      读 L2  = 8KB / 64B * 4-cycles / 2.4GHz = 213ns
      读 L3  = 8KB / 64B * 6-cycles / 2.4GHz = 320ns
      写 RAM = 8KB / 64B * 13.125ns = 1680ns
      估计值 = L1 107ns + L2 213ns * 0.2 + L3 320ns * 0.04 + RAM 1680ns = 1842ns

    如果 CPU 用 Write-Allocate 或者 Write-Back (through on miss) 策略, 这样读写都走 cache,  并且 miss allocate 会引发 dirty cacheline 的回写: 
      读 L1  = 8KB / 64B * 2-cycles / 2.4GHz = 107ns
      读 L2  = 8KB / 64B * 4-cycles / 2.4GHz = 213ns
      读 L3  = 8KB / 64B * 6-cycles / 2.4GHz = 320ns
      写 L1  = 8KB / 64B * 2-cycles / 2.4GHz = 107ns
      写 L2  = 8KB / 64B * 7-cycles / 2.4GHz = 373ns
      写 L3  = 8KB / 64B * 11-cycles / 2.4GHz = 587ns
      W-A 估计值  = L1 107ns + L2 213ns * 0.2 + L3 320ns * 0.04 + 
                      L1 107ns + L2 213ns * 0.2 + L3 320ns * 0.04 + 
                      L2 373ns * 0.4 + L3 587ns * 0.08 = 521ns
      W-B 估计值  = L1 107ns + L2 213ns * 0.2 + L3 320ns * 0.04 + 
                      L1 107ns * 0.8 + L2 373ns * 0.16 + L3 587ns * 0.04 + 
                      L2 373ns * 0.2 + L3 587ns * 0.04 = 449ns

    实际验证一下,下面测出是 450-500ns:

    实测的结果证明 Intel i5 CPU 是用 Write-Back 策略管理缓存,实际上也是这样的。网上资料可以证明 Intel 自 486 CPU 后 cache 都采用 Write-Allocate 模式。

结果说明了什么?
   1) 因为需要在创建数组后置 0,  产生 cache miss,  导致 JAVA 创建数组所需时间和数组大小成正比。
   2) 作为上一条的副作用,JAVA 创建的数组已经进入 L2 cache (甚至 L1), 后续的访问效率很高。

我们应该怎样做?
   1) 在要求性能的代码中,尽量少创建数组,尽量复用上次创建的数组。
   2) 使用 HashMap 或者 ArrayList 之类内部依赖数组的数据结构时,初始大小不要太大,需要时再扩充比较好 (如果分配 256,实际需要 512,损失 50% 效率。如果分配 1024,实际需要 512,损失 100% 效率)。
   3) 创建数组和访问数组的代码尽量在一起。

其他: 有没考虑的因素吗?
     问题 1:覆写新数组没有考虑 L3 回写到 RAM 的影响?
    假设 L3 cache 策略是 Write-Allocate,如果产生 L3 cache miss, 除了要从 RAM 加载 cacheline, 还会造成被淘汰的 cacheline 回写。极限情况下, 每次 L3 cache miss 可能全部需要回写, 需要传输双倍的数据。
    如果有 30% 的 L3 cacheline 需要回写: 
     总计   = 1680ns + 1680ns * 0.3 = 2184ns

     问题 2:是否有 TLB miss 的影响? 
    Intel CPU 的内存页面大小是 4KB, 测试的工作集仅 16KB, 需要占用 4 个 TLB item. 
    如果全部 TLB miss, 仅仅会增加 4 * 7 cycles / 2.4GHz = 12ns.

     问题 3:GC 对性能的影响? 为了计算 GC 对计算的影响, 我打开了 -verbose:gc 选项:
     [GC 16582K->395K(260160K), 0.0002036 secs]
     [GC 16580K->395K(260160K), 0.0002074 secs]
     [GC 16585K->395K(260160K), 0.0002019 secs]
     ...
    在 -Xms256m -Xmx256m 参数下,代码每运行 20000 次产生 10 次 minor-gc, 每次大约 0.2ms, 这样影响是:
     实测   = 10 * 0.2ms / 20000 = 100ns
    有些影响, 但不至于影响结论。

     问题 4:换台机器,计算方法还有效吗?
    在服务器 (Xeon L5630 2.13GHz, 虚拟机) 上检验一下 (OpenJDK 64-Bit Server VM):
      (CPU 参数来自评测,Xeon 是服务器系列,主频稍低、延迟稍高)
      L1  = 8KB / 64B * 4-cycles / 2.13GHz = 240ns
      L2  = 8KB / 64B * 11-cycles / 2.13GHz = 661ns
      L3  = 8KB / 64B * 13-cycles / 2.13GHz = 781ns
      估计值 = L1 240ns + L2 661ns * 0.2 + L3 781ns * 0.04 + // 读
                     L1 240ns + L2 661ns * 0.2 +                              // 写
                     L2 661ns * 0.4 + L3 781ns * 0.04 = 1071ns
      实测值 = 1298ns
    虚拟机 dmidecode 命令不可用, 不知道内存类型, 但是服务器内存带 ECC 效验, 每 64bit 增加 8bit 效验位,多消耗 1 个 CPU cycle:
      估计值 = (8KB / 64 * 72) / 64B * (13.125ns + 1/2.13GHz) = 1958ns
      实测值 = 2458ns
目录
相关文章
|
1月前
|
安全 Java 编译器
JDK 10中的局部变量类型推断:Java编程的简化与革新
JDK 10引入的局部变量类型推断通过`var`关键字简化了代码编写,提高了可读性。编译器根据初始化表达式自动推断变量类型,减少了冗长的类型声明。虽然带来了诸多优点,但也有一些限制,如只能用于局部变量声明,并需立即初始化。这一特性使Java更接近动态类型语言,增强了灵活性和易用性。
106 53
|
15天前
|
安全 Java 编译器
一个 Bug JDK 居然改了十年?
你敢相信么一个简单的Bug,JDK 居然花了十年时间才修改完成。赶快来看看到底是个什么样的 Bug?
28 1
一个 Bug JDK 居然改了十年?
|
19天前
|
Oracle 安全 Java
深入理解Java生态:JDK与JVM的区分与协作
Java作为一种广泛使用的编程语言,其生态中有两个核心组件:JDK(Java Development Kit)和JVM(Java Virtual Machine)。本文将深入探讨这两个组件的区别、联系以及它们在Java开发和运行中的作用。
43 1
|
28天前
|
IDE Java 编译器
开发 Java 程序一定要安装 JDK 吗
开发Java程序通常需要安装JDK(Java Development Kit),因为它包含了编译、运行和调试Java程序所需的各种工具和环境。不过,某些集成开发环境(IDE)可能内置了JDK,或可使用在线Java编辑器,无需单独安装。
61 1
|
2月前
|
存储 缓存 算法
Java 数组
【10月更文挑战第19天】Java 数组是一种非常实用的数据结构,它为我们提供了一种简单而有效的方式来存储和管理数据。通过合理地使用数组,我们能够提高程序的运行效率和代码的可读性。更加深入地了解和掌握 Java 数组的特性和应用,为我们的编程之旅增添更多的精彩。
33 4
|
2月前
|
缓存 Java Maven
java: 警告: 源发行版 11 需要目标发行版 11 无效的目标发行版: 11 jdk版本不符,项目jdk版本为其他版本
如何解决Java项目中因JDK版本不匹配导致的编译错误,包括修改`pom.xml`文件、调整项目结构、设置Maven和JDK版本,以及清理缓存和重启IDEA。
58 1
java: 警告: 源发行版 11 需要目标发行版 11 无效的目标发行版: 11 jdk版本不符,项目jdk版本为其他版本
|
2月前
|
设计模式 Java API
[Java]静态代理与动态代理(基于JDK1.8)
本文介绍了代理模式及其分类,包括静态代理和动态代理。静态代理分为面向接口和面向继承两种形式,分别通过手动创建代理类实现;动态代理则利用反射技术,在运行时动态创建代理对象,分为JDK动态代理和Cglib动态代理。文中通过具体代码示例详细讲解了各种代理模式的实现方式和应用场景。
31 0
[Java]静态代理与动态代理(基于JDK1.8)
|
2月前
|
存储 缓存 算法
提高 Java 数组性能的方法
【10月更文挑战第19天】深入探讨了提高 Java 数组性能的多种方法。通过合理运用这些策略,我们可以在处理数组时获得更好的性能表现,提升程序的运行效率。
40 2
|
2月前
|
存储 Java
Java“(array) <X> Not Initialized” (数组未初始化)错误解决
在Java中,遇到“(array) &lt;X&gt; Not Initialized”(数组未初始化)错误时,表示数组变量已被声明但尚未初始化。解决方法是在使用数组之前,通过指定数组的大小和类型来初始化数组,例如:`int[] arr = new int[5];` 或 `String[] strArr = new String[10];`。
93 2
|
2月前
|
存储 Java
什么是带有示例的 Java 中的交错数组?
什么是带有示例的 Java 中的交错数组?
53 9