转载

读书笔记——Java虚拟机自动内存管理机制

对于从事 C C++ 程序开发的开发人员来说,在 内存管理领域 ,他们 既拥有每一个对象的“所有权”又担负着每一个对象生命开始到终结的维护责任

对于 Java 程序员来说,在 Java虚拟机自动内存管理机制 的帮助下, 不再需要为每一个new操作去写配对的delete/free代码不容易出现内存泄漏和内存溢出问题 ,这看起来一切美好,不过正是因为 Java 程序员把 内存控制 的权力交给 Java虚拟机 ,一旦出现 内存泄漏内存溢出 的问题的时候,如果不了解 Java虚拟机 是怎样使用 内存 的话,那么 排查错误 将会一项 异常艰难 的工作。

运行时数据区域

Java虚拟机在执行 Java程序 的过程中会把它所管理的 内存 划分为 若干个不同的数据区域 。这些 区域 有各自的用途,以及 创建销毁 的时间,有的 区域 随着 Java虚拟机进程启动存在 ,有的 区域 则依赖 用户线程启动结束建立销毁 。根据**《Java虚拟机规范(Java SE 7版)》 的规定, Java虚拟机 所管理的 内存 将会包括以下几个 运行时数据区域**,如下图所示:

读书笔记——Java虚拟机自动内存管理机制

由所有线程共享的数据区:

  • 方法区
  • 直接内存

线程隔离的数据区:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

程序计数器

程序计数器(Program Counter Register) 是一块 较小 的 内存空间 ,它可以看作是当前 线程 所执行的 字节码行号指示器 。在 虚拟机 概念模型(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现) 里, 字节码解释器 工作时就是通过改变这个 计数器 来选取下一条需要执行的 字节码指令分支循环跳转异常处理线程恢复基础功能 都需要依赖这个 计数器 来完成。

由于 Java虚拟机多线程 是通过 线程轮流切换分配处理器执行时间 的方式来实现的,在任何一个确定的时刻, 一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令 。因此,为了 线程切换 后能 恢复正确的执行位置每条线程都需要有一个独立的程序计数器各条线程之间计数器互不影响独立存储 ,我们称这类 内存区域 为**“线程私有” 内存**。

  • 如果 线程 正在执行的是一个 Java方法 ,这个 计数器 记录的是 正在执行虚拟机字节码指令的地址
  • 如果 线程 正在执行的是一个 Native方法 ,这个 计数器值 则为 空(Undefined)

此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈

程序计数器 一样, Java虚拟机栈(Java Virtual Machine Stacks) 也是 线程私有 的, 它的生命周期与线程相同虚拟机栈 描述的是 Java方法 执行的 内存模型 ,每个方法在执行的同时都会创建一个 栈帧(Stack Frame) 用于存储 局部变量表 操作数栈动态链接方法出口 等消息。每一个方法从 调用 直至 执行完成 的过程,就对应着一个 栈帧虚拟机栈入栈出栈 的过程。

经常有人把 Java内存 区分为 堆内存(Heap) 栈内存(Stack) ,这种分法 比较粗糙Java内存区域划分 实际上远比这 复杂 。这种 划分方式 的流行只能说明大多数程序员 最关注的与对象内存分配关系最密切的内存区域这两块 。其中所指的**“堆” 后面会讲到,而所指的就是现在讲的 Java虚拟机栈中的局部变量表部分**。

局部变量表存放了 编译器 可知的各种 基本数据类型(boolean、byte、char、short、int、float、long、double) 对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置) returnAddress类型(指向了一条字节码指令的地址)

其中 64位长度longdouble 类型的数据会占用 两个局部变量空间(Slot)其余的数据类型 只占用 一个局部变量表 所需的 内存空间编译期间 完成分配,当进入一个 方法 时,这个 方法 需要在 中分配多大的 局部变量空间完全确定 的,在 方法运行期间 不会改变 局部变量表大小

Java虚拟机规范 中,对这个 区域 规定了 两种异常状态

  • 如果 线程请求的栈深度大于虚拟机所允许的的深度 ,将抛出 StackOverflowError 异常。
  • 如果 虚拟机栈 可以 动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈) ,如果 扩展时无法申请到足够的内存 ,就会抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈(Native Method Stack) 虚拟机栈 所发挥的作用是 非常相似 的,它们之间的 区别 不过是 虚拟机栈虚拟机 执行 Java方法(也就是字节码)服务 ,而 本地方法栈 则为 虚拟机 使用到的 Native方法服务 。在 虚拟机规范 中对 本地方法栈方法使用的语言使用方式数据结构没有强制规定 ,因此具体的 虚拟机 可以 自由实现它 。甚至有的 虚拟机(例如:Sun HotSpot虚拟机) 直接就把 虚拟机栈 本地方法栈 合二为一。与 虚拟机栈 一样, 本地方法栈 区域也会抛出 StackOverflowErrorOutOfMemoryError 异常。

Java堆

对于 大多数应用 来说, Java堆(Java Heap) Java虚拟机 所管理的的内存中 最大 的一块。 Java堆是被所有线程共享的一块内存区域 ,在 虚拟机 启动时 创建 。此 内存区域唯一目的 就是 存放对象实例几乎所有的对象实例 都在这里 分配内存 。这一点在 Java虚拟机规范 中描述是: 所有的对象实例以及数组都要在堆上分配 ,但是随着 JIT编译器 的发展与 逃逸分析技术 逐渐成熟, 栈上分配标量替换 优化技术将会导致一些微妙的变化发生, 所有的对象 都分配在 上也渐渐变得不是那么**”绝对“**了。

Java堆是 垃圾收集器 管理的 主要区域 ,因此很多时候也被称做**”GC堆(Grabage Collected Heap) 。从 内存回收 的角度来看,由于现在 收集器 基本采用 分代收集算法**,所以 Java堆 中还可以细分为: 新生代老年代 ;再 细致一点 的有 Eden空间From Survivor空间To Survivor空间 等。从 内存分配 的角度来看, 线程共享Java堆 中可能划分出 多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB) 。不过无论如何划分,都与 存放内容无关 ,无论哪个区域,存储的仍然是 对象实例 ,进一步划分的目的是为了更好地 回收内存 ,或者更快地 分配内存

根据 Java虚拟机规范 的规定, Java堆 可以处于 物理上不连续的内存空间 中,只要逻辑上是 连续 即可,就像我们的 磁盘 一样。在 实现 时,既可以实现成 固定大小 的,也可以是 可扩展 的,不过当前主流的 虚拟机 都是按照 可扩展 来实现的**(通过-Xmx和-Xms控制) 。如果在 没有内存完成实例分配**,并且 无法扩展 时,将会抛出 OutOfMemoryError 异常。

方法区

方法区(Method Area) Java堆 一样, 是各个线程共享的内存区域 ,它用于存储已被 虚拟机 加载的 类信息常量静态变量即时编译器编译后的代码数据 。虽然 Java虚拟机规范方法区 描述为 一个逻辑部分 ,但是它却有一个别名叫做 非堆(Non-Heap) ,目的应该是与 Java堆 区分开来。

对于习惯在 HotSpot虚拟机开发部署程序 的开发者来说,很多人都更愿意把 方法区 称为**“永久代”(Permanent Generation) ,本质上 两者并不等价**,仅仅是因为 HotSpot虚拟机 的设计团队选择把 GC分代收集 扩展到 方法区 ,或者说使用 永久代 来实现 方法区 而已,这样 HotSpot垃圾收集器 可以像管理 Java堆 一样管理这部分 内存 ,能够省去专门为 方法区 编写 内存管理代码 的工作。对于其他 虚拟机(例如:BEA JRockit、IBM J9等等) 来说是 不存在永久代 的概念的。原则上,如何实现 方法区 属于 虚拟机 实现细节,不受 虚拟机规范 约束,但是使用 永久代 来实现 方法区 ,现在看来 并不是一个好主意 ,因为这样更容易遇到 内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如:32系统中的4GB,就不会出现问题) ,而且有 极少数方法(例如:String.intern()) 会因为这个原因导致 不同虚拟机 下有 不同 的表现。因此,对 HotSpot虚拟机 ,根据官方发布的 路线图信息 ,现在也有 放弃永久代 并逐步改为采用 Native Memory 来实现 方法区 的规划了,在目前已经发布的 JDK 1.7HotSpot 中,已经把原本放在 永久代字符串常量池 移出。

Java虚拟机规范对 方法区 的限制 非常宽松 ,除了和 Java堆 一样 不需要连续的内存可以选择固定大小 或者 可扩展 外,还可以选择 不实现垃圾收集 。相对而言, 垃圾收集行为 在这个区域是 比较少出现的 ,但是并非数据进入了 方法区 就如 永久代 的名字一样**“永久” 存在了。这个区域的 内存回收 目标主要是针对 常量池的回收 对类型的卸载**,一般来说,这个区域的 回收“成绩” 比较难令人满意,尤其是 类型的加载 ,条件相当苛刻,但是这部分区域的 回收 确实是 必要的 。在 Sun公司BUG列表 中,曾出现过的若干个 严重BUG 就是由于 低版本HotSpot虚拟机 对此区域 未完全回收 而导致 内存泄漏

根据 Java虚拟机规范 的规定,当 方法区 无法满足 内存分配 需要时,将抛出 OutOfMemoryError 异常。

运行时常量池

运行时常量池(Runtime Constant Pool) 方法区 的一部分。 Class文件 中除了有 版本字段方法接口 等描述信息外,还有一项信息是 常量池(Constant Pool Table) ,用于存放 编译器 生成的各种 字面量符号引用 ,这部分内容将在 类加载 后进入 方法区运行时常量池 中存放。

Java虚拟机对 Class文件 每一部分(自然也包括 常量池 )的格式都有严格规定,每一个 字节 用于存储哪种数据都 必须符合 规范上的要求才会被虚拟机 认可装载执行 ,但是对于 运行时常量池Java虚拟机规范 没有做任何细节的要求,不同的提供商实现的 虚拟机 可以按照自己的需要来实现这个 内存区域 。不过,一般来说,除了保存 Class文件 中描述的 符号引用 外,还会把翻译出来的 直接引用 也存储在 运行时常量池 中。

运行时常量池相对于 Class文件常量池 的另外一个重要特征是 具备动态性Java语言 并不要求 常量 一定只有 编译期 才能产生,也就是并非预置入 Class文件常量池 的内容才能进入 方法区运行时常量池运行期间 也可能将新的 常量 放入池中,这种特性被开发人员利用得比较多的便是 String类 的**intern()**方法。

既然 运行时常量池方法区 的一部分, 自然受到方法区内存的限制 ,当 常量池 无法再申请到内存时就会抛出 OutOfMemoryError 异常。

直接内存

直接内存(Direct Memory) 并不是 虚拟机 运行时数据区的一部分,也不是 Java虚拟机规范 中定义的 内存区域 。但是这部分 内存被频繁地使用 ,而且也可能导致 OutOfMemoryError 异常出现,所以我们放在这里一起讲解。

JDK1.4 中新加入了 NIO(New Input/Output)类 ,引入了一种基于 通道(Channel) 缓冲区(Buffer) I/O 方式,它可以使用 Native函数库 直接分配 堆外内存 ,然后通过一个存储在 Java堆 中的 DirectByteBuffer 对象作为这块 内存的引用 进行操作。这样就能在一些场景中 显著提高性能 ,因为避免了在 Java堆Native堆来回复制数据

显然, 本机直接内存 的分配不会受到 Java堆 大小的限制,但是,既然是 内存 ,肯定还是会受到 本机总内存(包括RAM以及SWAP区或者分页文件) 大小以及 处理器寻址空间 的限制。服务器管理员在配置 虚拟机 参数时,会根据实际内存设置**-Xmx 等参数信息,但是经常忽略 直接内存**,使得 各个内存区域总和大于物理内存限制(包括物理和操作系统级的限制) ,从而导致 动态扩展 时出现 OutOfMemoryError 异常。

HotSpot虚拟机对象探秘

介绍完 Java虚拟机运行时数据区 之后,我们大致知道了 虚拟机内存 的概况,在了解内存放了些什么后,也许就会想更进一步了解这些 虚拟机 内存中的数据的其他细节,譬如它们是如何 创建 、如何 布局 以及如何 访问 的。对于这样涉及细节的问题,必须把讨论范围限定在具体的 虚拟机 和集中在某一个内存区域上才有意义。基于 实用优先 的原则,我以常用的 虚拟机HotSpot 和常用的 内存区域Java堆 为例,深入探讨 HotSpot虚拟机Java堆对象分配布局访问 的全过程。

对象的创建

Java是一门 面向对象编程语言 ,在 Java 程序运行过程中无时无刻都有对象被 创建 出来。在 语言层面 上, 创建对象(例如:克隆、反序列化) 通常仅仅是一个 new关键字 而已,而在 虚拟机 中, 对象(文章中讨论的对象仅限于普通的Java对象,不包括数组和Class对象等) 创建 又是怎样一个过程呢?

虚拟机遇到一条 new指令 时,首先将去检查 这个指令的参数 是否能在 常量池 中定位到一个 符号引用 ,并且检查这个 符号引用 代表的 是否已被 加载解析初始化 过。如果没有,那必须先执行相应的 类加载过程

类加载检查 通过后,接下来 虚拟机 将为 新生对象 分配内存。 对象 所需内存的大小在 类加载 完成后便可 完全确定 ,为 对象 分配空间的任务等同于把一块 确定大小 的内存从 Java堆 中划分出来。假设 Java堆 中内存时 绝对规整的所有用过的内存都放在一边空闲的内存放在另一边中间放着一个指针作为分界点的指示器 ,那所分配 内存 就仅仅是把那个 指针空闲空间 那边 挪动一段与对象大小相等的距离 ,这种 分配方式 成为**“指针碰撞”(Bump the Pointer) 。如果 Java堆 中的内存 并不是规整的**, 已使用的内存和空闲的内存相互交错 ,那就没有办法简单地进行 指针碰撞 了, 虚拟机 就必须维护一个 列表 ,记录上哪些 内存块 是可用的,在 分配 的时候从 列表 中找到一块 足够大的空间 划分给 对象实例 ,并 更新列表上的记录 ,这种 分配方式 成为**“空闲列表”(Free List) 选择哪种分配方式由Java堆是否规整决定而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定 。因此,在使用 Serial**、 ParNew 等带 Compact 过程的 收集器 时,系统采用的 分配算法指针碰撞 ,而使用 CMS 这种基于 Mark-Sweep 算法的 收集器 时,通常采用 空闲列表

除如何划分 可用空间 之外,还有另外一个需要考虑的问题是 对象创建虚拟机 中是 非常频繁 的行为,即使是仅仅修改一个 指针 所指向的位置,在 并发情况 下也并不是 线程安全 的,可能出现正在给 对象A 分配内存, 指针 还没来得及修改, 对象B 又同时使用了原来的 指针分配内存 的情况。解决这个问题有 两种方案

  • 分配内存空间 的动作进行 同步处理 ——实际上 虚拟机 采用 CAS 配上 失败重试 的方式保证更新操作的 原子性
  • 内存分配 的动作按照 线程 划分到 不同的空间 之中进行,即 每个线程Java堆预先分配 一小块内存,称为 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB) 。哪个 线程分配内存 ,就在哪个 线程TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要 同步锁定虚拟机 是否使用 TLAB ,可以通过**-XX:+/-UseTLAB**参数来设定。

内存分配完成后, 虚拟机 需要将分配到的 内存空间初始化零值(不包括对象头) ,如果使用 TLAB ,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了 对象实例字段Java代码 中可以 不赋初始值直接使用 ,程序能访问到这些 字段数据类型 所对应的 零值

接下来, 虚拟机 要对 对象 进行 必要的设置 ,譬如这个 对象 是哪个类的 实例 、如何才能找到 元数据信息对象的哈希码对象的GC分代年龄 等信息。这些信息存放在 对象 对象头(Object Header) 之中。根据 虚拟机 当前的 运行状态 的不同,例如:是否启用 偏向锁 等, 对象头 会有不同的 设置方式

在上面工作都 完成 后,从 虚拟机 的视角来看, 一个新的对象已经产生了 ,但是从 Java程序 的视角来看, 对象创建才刚刚开始——方法还没有执行,所有的字段都还为零 。所以, 一般来说(由字节码中是否跟随invokespecial指令所决定) ,执行 new指令 之后会接着执行**方法**,把 对象 按照程序员的意愿进行 初始化 ,这样一个 真正可用对象 才算 完全生产出来

总结一下对象的创建过程:

  1. 类加载检查
  2. 分配内存
  3. 初始化零值
  4. 设置对象头
  5. 执行init方法

对象的内存布局

HotSpot虚拟机 中, 对象内存存储的布局 可以分为 三块区域对象头(Header) 实例数据(Instance Data) 对齐填充(Padding)

对象头

HotSpot虚拟机的 对象头 包括 两部分信息

  • 第一部分用于 存储对象自身的运行时数据 ,例如: 哈希码(HashCode)GC分代年龄锁状态标志线程持有的锁偏向线程ID偏向时间戳 等,这部分数据的长度在 32位64位 虚拟机(未开启压缩指针) 中分别为 32bit 64bit ,官方称它为**“Mark Word” 对象 需要存储的运行时数据 很多**,其实已经超出了 32位64位Bitmap结构 所能记录的 限度 ,但是 对象头信息 是与 对象 自身定义的数据 无关额外存储成本 ,考虑到 虚拟机空间效率Mark Word 被设计成一个 非固定数据结构 以便在 极小 的空间内存储 尽量多 的信息,它会根据 对象 的状态 复用 自己的 存储空间 ,例如:在 32位HotSpot虚拟机 中,如果 对象 处于 未被锁定 的状态下,那么 Mark Word32bit 空间中的 25bit 用于 存储对象哈希码4bit 用于 存储对象分代年龄2bit 用于 存储锁标志位1bit 固定为 0 ,而在 其他状态(轻量级锁定、重量级锁定、GC标记、可偏向) 对象 存储内容 如下图所示:

    读书笔记——Java虚拟机自动内存管理机制
  • 第二部分是 类型指针 ,即 对象指向它的类元数据的指针虚拟机 通过这个 指针 来确定 这个对象哪个类实例 。并不是所有的 虚拟机 实现都必须在 对象数据保留类型指针 ,换句话说,查找 对象元数据信息 并不一定要经过 对象本身 ,这点将在下面要讲的 对象的访问定位 讲解。另外,如果 对象 是一个 Java数组 ,那在 对象头 中必须有一块用于记录 数组长度 的数据,因为 虚拟机 可以通过 普通Java对象元数据信息 确定 Java对象大小 ,但是从 数组元数据 中却 无法确定数组的大小

实例数据

实例数据是 对象真正存储的有效信息也是在程序代码中所定义的各种类型的字段内容 。无论是从 父类 继承下来的,还是在 子类 中定义的,都需要 记录 起来。这部分的 存储顺序 会受到 虚拟机分配策略参数(FieldsAllocationStyle) 字段在Java源码中定义顺序 的影响。 HotSpot虚拟机 默认的 分配策略longs/doublesintsshorts/charsbytes/booleansoops(Ordinary Object Pointers) ,从 分配策略 中可以看出, 相同宽度的字段总是被分配到一起 。在满足这个 前提条件 的情况下,在 父类 中定义 变量 会出现在 子类 之前。如果 CompactFields 参数值为 true(默认为true) ,那么 子类 之中 较窄变量 也可能会插入到 父类变量 的空隙之中。

对齐填充

对齐填充不是必然存在的,也没有特别的定义,它仅仅起着占位符的作用。由于 HotSpot VM自动内存管理系统 要求 对象起始地址 必须是 8字节整数倍 ,换句话说,就是 对象大小 必须是 8字节整数倍 。而 对象头 部分正好是 8字节倍数(1倍或者2倍) ,因此,当 对象实例数据部分没有对齐 时,就需要通过 对齐填充补全

对象的访问定位

建立对象是为了 使用对象 ,我们的 Java程序 需要通过 上的 reference 数据来操作 上的 具体对象 。由于 reference 类型在 Java虚拟机规范只规定一个指向对象的引用并没有定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置 ,所以 对象访问方式 也是取决于 虚拟机 实现而定的。目前 主流访问方式 有使用 句柄直接指针 两种:

  • 如果使用 句柄 访问的话,那么 Java堆 中将会 划分出一块内存 来作为 句柄池reference 中存储的就是 对象句柄地址 ,而 句柄 中包含了对象 实例数据类型数据 各自的 具体地址信息 ,如下图所示:

    读书笔记——Java虚拟机自动内存管理机制
  • 如果使用 直接指针 访问的话,那么 Java堆对象的布局 中就必须考虑如何放置 访问类型数据相关信息 ,而 reference 中存储的直接就是 对象地址 ,如下图所示:

    读书笔记——Java虚拟机自动内存管理机制

两种对象访问方式 各有 优势 ,使用 句柄 来访问的 最大好处 就是 reference 中存储的是 稳定的句柄地址 ,在 对象被移动(垃圾收集时移动对象是非常普遍的行为) 只会改变句柄中实例数据指针 ,而 reference 本身 不需要修改

使用 直接指针 访问方式的 最大好处 就是 速度更快它节省了一次指针定位的时间开销 ,由于 对象的访问Java非常频繁 ,因此这里开销 积少成多 后也是一项 非常可观执行成本 。本文章讨论的 虚拟机Sun HotSpot 使用的是 第二种方式 ,也就是使用 直接指针 进行 对象访问 的,但是从 整个软件开发的范围 来看,各种 语言框架 使用 句柄 来进行 对象访问 也是 十分常见的

题外话

我想聊一下 Java基本数据类型包装类常量池String类型常量池

Java基本数据类型包装类常量池

Java基本数据类型中的 byteshortintlongbooleanchar包装类 使用了 常量池 ,它们只在**[-128, 127] 范围内使用相应 类型 缓存数据**, 超出这个范围 的就会 创建新的对象 ,而 floatdouble包装类 没有使用 常量池

举个例子,代码如下所示:

/**
 * Created by TanJiaJun on 2020/6/26.
 */
public class ConstantPoolTest {

    public static void main(String[] args) {
         Integer i1 = 3;
         Integer i2 = 4;
         Integer i3 = 7;
         Integer i4 = 7;
         Integer i5 = 777;
         Integer i6 = 777;
         Integer i7 = new Integer(3);
         Integer i8 = new Integer(4);
         Integer i9 = new Integer(7);
         Double d1 = 7.7;
         Double d2 = 7.7;

         System.out.println(i3 == i4);      // true
         System.out.println(i1 + i2 == i3); // true
         System.out.println(i5 == i6);      // false
         System.out.println(i3 == i9);      // false
         System.out.println(i7 + i8 == i9); // true
         System.out.println(i7 + i8 == 7);  // true
         System.out.println(d1 == d2);      // false
    }

}
复制代码

Java 中, == 两个 作用:

  • 比较的是Java基本数据类型的话,就是比较它们的值是否相等。
  • 比较的是引用类型的话,就是比较它们的引用地址是否相同。

解析:

  • i3 == i4,返回truei3i4 都是 Integer 类型,它们的 相等 ,而且都在**[-128, 127] 范围内,所以 它们同时使用着常量池中的对象,也就是它们是同一个对象**,因此返回 true
  • i1 + i2 == i3,返回truei1i2i3 都是 Integer 类型, 加号不适用于Integer对象编译器 会对 i1i2 进行 自动拆箱 ,进行 数值相加 ,然后变成 7 == i3 ,因为 i3 也是 Integer 类型, 它无法和数值进行直接比较 ,所以 编译器 也会对 i3 进行 自动拆箱 ,最后就变成 数值的比较7 == 7 ,所以返回 true
  • i5 == i6,返回falsei5i6 都是 Integer 类型,它们的 相等 ,但是不在**[-128, 127] 范围内,所以 它们都各自创建新的对象,也就是它们不是同一个对象**,因此返回 false
  • i3 == i9,返回falsei3i9 都是 Integer 类型, i3会使用Integer常量池的对象,而i9会创建新的Integer对象 ,所以 它们不是同一个对象 ,因此返回 false
  • i7 + i8 == i9,返回truei7i8i9 都是 Integer 类型, 加号不适用于Integer对象编译器 会对 i7i8 进行 自动拆箱 ,进行 数值相加 ,然后变成 7 == i9 ,因为 i9 也是 Integer 类型, 它无法和数值进行直接比较 ,所以 编译器 也会对 i9 进行 自动拆箱 ,最后就变成 数值的比较7 == 7 ,所以返回 true
  • i7 + i8 == 7,返回truei7i8 都是 Integer 类型, 加号不适用于Integer对象编译器 会对 i7i8 进行 自动拆箱 ,进行 数值相加 ,最后变成 数值的比较7 == 7 ,所以返回 true
  • d1 == d2,返回falsed1d2 都是 Double 类型,它们的 相等 ,但是没有使用 常量池 ,所以 它们都各自创建新的对象,也就是它们不是同一个对象 ,因此返回 false

当声明为如上述示例代码中的 i1i2i3i4 时, 编译器 会帮我们 自动装箱 ,调用 Integer 类的 valueOf 方法,看下相关的源码,源码如下所示:

// Integer.java
package java.lang;

import java.lang.annotation.Native;

public final class Integer extends Number implements Comparable<Integer> {

    private static class IntegerCache {
        // 缓存的最小值是-128
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // 缓存的最大值是127
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    int i = parseInt(integerCacheHighPropValue);
                    i = Math.max(i, 127);
                    // 数组的最大大小为Integer.MAX_VALUE
                    h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // 如果不能将该属性解析为int,就忽略它
                }
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            for(int k = 0; k < cache.length; k++)
                cache[k] = new Integer(j++);

            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

}
复制代码

可以看到 Integer 类的 valueOf 方法, 如果是大于等于IntegerCache.low的值(-128),同时小于等于IntegerCache.high的值(127),就会使用IntegerCache,也就是使用缓存,否则就创建新的Integer对象

这里顺便提一下 equals 方法,它和**== 有什么区别呢?先看下 Object 类的 equals**方法,源码如下所示:

// Object.java
public class Object {

    // 省略部分代码

    public boolean equals(Object obj) {
        return (this == obj);
    }

    // 省略部分代码

}
复制代码

可以看到 equals 方法的逻辑就是**== ,然后看下 Integer 类的 equals**方法,源码如下所示:

// Integer.java
public final class Integer extends Number implements Comparable<Integer> {

    // 省略部分代码

    // Integer的值
    private final int value;

    // 以int的形式返回该Integer的值
    public int intValue() {
        return value;
    }

    public boolean equals(Object obj) {
        // 判断参数obj是否为Integer类的实例
        if (obj instanceof Integer) {
            // 如果参数obj是Integer类的实例,就调用它的intValue方法得到值,并且判断value是否与该值相等
            return value == ((Integer)obj).intValue();
        }
        // 如果参数obj不是Integer类的实例,就返回false
        return false;
    }

    // 省略部分代码

}
复制代码

再看下 String 类的 equals 方法,源码如下所示:

// String.java
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    // 省略部分代码

    public boolean equals(Object anObject) {
        // 判断参数anObject的引用地址是否与该对象相同
        if (this == anObject) {
            // 如果参数anObject的引用地址与该对象相同,就返回true
            return true;
        }
        // 如果参数anObject的引用地址与该对象不相同,就判断anObject是否为String类的实例
        if (anObject instanceof String) {
            // 强制转成String对象
            String anotherString = (String)anObject;
            int n = length();
            if (n == anotherString.length()) {
                int i = 0;
                // 判断String类型的参数anObject中的每个字符是否与该对象的每个字符相等
                while (n-- != 0) {
                    if (charAt(i) != anotherString.charAt(i))
                            // 如果String类型的参数anObject中的有其中一个字符与该对象的其中一个字符不相等,就返回false
                            return false;
                    i++;
                }
                // 如果String类型的参数anObject中的每个字符都与该对象的每个字符相等,就返回true
                return true;
            }
        }
        // 如果参数anObject不是String类的实例,就返回false
        return false;
    }

    // 省略部分代码

}
复制代码

可以看到 Integer 类和 String重写Object 类的 equals 方法,逻辑也改成 判断对应类型的值是否相等

字符串常量池

JDK 1.7 之后(包括 JDK 1.7 ), 字符串常量池方法区 移动到

字面量声明

示例代码如下:

String str = "谭嘉俊";
复制代码

这种 声明方式 叫做 字面量声明 ,它是把 字符串双引号 包起来,然后 赋值 给一个 变量 ,这种情况下,它会把 字符串 放到 字符串常量池 ,然后返回给 变量

new String()

示例代码如下:

String str = new String("谭嘉俊");
复制代码

使用 new String() 方法不管在 字符串常量池 中有没有,它都会在 创建一个新的对象

intern()

源码代码如下:

// String.java
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    // 省略部分代码

    public native String intern();

}
复制代码

可以看到 intern 方法是个 native 方法。

字符串常量池最初是空的,由String类私有地维护,当intern方法被调用的时候,如果当前字符串存在于字符串常量池,判断条件是使用equals方法是返回true的话,就会直接返回这个字符串在字符串常量池的引用,如果不存在,它就会在字符串常量池中创建一个引用,并且指向堆中已存在的字符串,然后返回对应的字符串常量池的引用。

举个例子,代码如下:

/**
 * Created by TanJiaJun on 2020/6/27.
 */
public class StringConstantPoolTest {

    public static void main(String[] args) {
         String str1 = "谭嘉俊";
         String str2 = "谭嘉俊";
         String str3 = "我叫";
         String str4 = new String(str1 + "谭嘉俊");
         String str5 = new String(str1 + "谭嘉俊");

         System.out.println(str1 == str2); // 1.true
         System.out.println(str1 == str4); // 2.false
         System.out.println(str4 == str5); // 3.false
         str4.intern();
         System.out.println(str1 == str4); // 4.false
         str4 = str4.intern();
         System.out.println(str1 == str4); // 5.true
         str5 = str5.intern();
         System.out.println(str4 == str5); // 6.true
    }

}
复制代码
  1. str1 == str2,返回truestr1str2 都是 字面量声明 ,而且 相等,所以 它们都指向字符串常量池中同一个对象 ,因此返回 true
  2. str1 == str4,返回falsestr1字面量声明 ,它是从 字符串常量池 取出来的, str4 是在 创建对象 ,所以 它们不是同一个对象 ,因此返回 false
  3. str4 == str5,返回falsestr4str5 各自都在 创建对象 ,所以 它们不是同一个对象 ,因此返回 false
  4. str1 == str4,返回false :虽然 str4 调用了 intern 方法,但是 没有返回 ,所以 它们还是不是同一个对象 ,因此返回 false
  5. str1 == str4,返回truestr4 调用了 intern 方法,并且返回给 str4 ,所以 它们都指向字符串常量池中同一个对象 ,因此返回 true
  6. str4 == str5,返回true :前面的逻辑, str4 调用了 intern 方法, str5 也调用了 intern 方法,所以 它们都指向字符串常量池中同一个对象 ,因此返回 true

参考文献:

[1] 周志明,深入理解Java虚拟机(第2版)[M],机械工业出版社,2013年9月1日,37页~49页

我的GitHub: TanJiaJunBeyond

Android通用框架: Android通用框架

我的掘金: 谭嘉俊

我的简书: 谭嘉俊

我的CSDN: 谭嘉俊

原文  https://juejin.im/post/5ef6594c5188252e8a080adf
正文到此结束
Loading...