对于从事 C 、 C++ 程序开发的开发人员来说,在 内存管理领域 ,他们 既拥有每一个对象的“所有权” , 又担负着每一个对象生命开始到终结的维护责任 。
对于 Java 程序员来说,在 Java虚拟机自动内存管理机制 的帮助下, 不再需要为每一个new操作去写配对的delete/free代码 , 不容易出现内存泄漏和内存溢出问题 ,这看起来一切美好,不过正是因为 Java 程序员把 内存控制 的权力交给 Java虚拟机 ,一旦出现 内存泄漏 和 内存溢出 的问题的时候,如果不了解 Java虚拟机 是怎样使用 内存 的话,那么 排查错误 将会一项 异常艰难 的工作。
Java虚拟机在执行 Java程序 的过程中会把它所管理的 内存 划分为 若干个不同的数据区域 。这些 区域 有各自的用途,以及 创建 和 销毁 的时间,有的 区域 随着 Java虚拟机进程 的 启动 而 存在 ,有的 区域 则依赖 用户线程 的 启动 和 结束 而 建立 和 销毁 。根据**《Java虚拟机规范(Java SE 7版)》 的规定, Java虚拟机 所管理的 内存 将会包括以下几个 运行时数据区域**,如下图所示:
程序计数器(Program Counter Register) 是一块 较小 的 内存空间 ,它可以看作是当前 线程 所执行的 字节码 的 行号指示器 。在 虚拟机 的 概念模型(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现) 里, 字节码解释器 工作时就是通过改变这个 计数器 的 值 来选取下一条需要执行的 字节码指令 , 分支 、 循环 、 跳转 、 异常处理 、 线程恢复 等 基础功能 都需要依赖这个 计数器 来完成。
由于 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位长度 的 long 和 double 类型的数据会占用 两个局部变量空间(Slot) , 其余的数据类型 只占用 一个 。 局部变量表 所需的 内存空间 在 编译期间 完成分配,当进入一个 方法 时,这个 方法 需要在 帧 中分配多大的 局部变量空间 是 完全确定 的,在 方法运行期间 不会改变 局部变量表 的 大小 。
在 Java虚拟机规范 中,对这个 区域 规定了 两种异常状态 :
本地方法栈(Native Method Stack) 与 虚拟机栈 所发挥的作用是 非常相似 的,它们之间的 区别 不过是 虚拟机栈 为 虚拟机 执行 Java方法(也就是字节码)服务 ,而 本地方法栈 则为 虚拟机 使用到的 Native方法服务 。在 虚拟机规范 中对 本地方法栈 中 方法使用的语言 、 使用方式 与 数据结构 并 没有强制规定 ,因此具体的 虚拟机 可以 自由实现它 。甚至有的 虚拟机(例如:Sun HotSpot虚拟机) 直接就把 虚拟机栈 和 本地方法栈 合二为一。与 虚拟机栈 一样, 本地方法栈 区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
对于 大多数应用 来说, 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.7 的 HotSpot 中,已经把原本放在 永久代 的 字符串常量池 移出。
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 异常。
介绍完 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 又同时使用了原来的 指针 来 分配内存 的情况。解决这个问题有 两种方案 :
内存分配完成后, 虚拟机 需要将分配到的 内存空间 都 初始化 为 零值(不包括对象头) ,如果使用 TLAB ,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了 对象 的 实例字段 在 Java代码 中可以 不赋初始值 就 直接使用 ,程序能访问到这些 字段 的 数据类型 所对应的 零值 。
接下来, 虚拟机 要对 对象 进行 必要的设置 ,譬如这个 对象 是哪个类的 实例 、如何才能找到 类 的 元数据信息 、 对象的哈希码 、 对象的GC分代年龄 等信息。这些信息存放在 对象 的 对象头(Object Header) 之中。根据 虚拟机 当前的 运行状态 的不同,例如:是否启用 偏向锁 等, 对象头 会有不同的 设置方式 。
在上面工作都 完成 后,从 虚拟机 的视角来看, 一个新的对象已经产生了 ,但是从 Java程序 的视角来看, 对象创建才刚刚开始——方法还没有执行,所有的字段都还为零 。所以, 一般来说(由字节码中是否跟随invokespecial指令所决定) ,执行 new指令 之后会接着执行**方法**,把 对象 按照程序员的意愿进行 初始化 ,这样一个 真正可用 的 对象 才算 完全生产出来 。
在 HotSpot虚拟机 中, 对象 在 内存 中 存储的布局 可以分为 三块区域 : 对象头(Header) 、 实例数据(Instance Data) 和 对齐填充(Padding) 。
HotSpot虚拟机的 对象头 包括 两部分信息 :
第一部分用于 存储对象自身的运行时数据 ,例如: 哈希码(HashCode) 、 GC分代年龄 、 锁状态标志 、 线程持有的锁 、 偏向线程ID 、 偏向时间戳 等,这部分数据的长度在 32位 和 64位 的 虚拟机(未开启压缩指针) 中分别为 32bit 和 64bit ,官方称它为**“Mark Word” 。 对象 需要存储的运行时数据 很多**,其实已经超出了 32位 、 64位Bitmap结构 所能记录的 限度 ,但是 对象头信息 是与 对象 自身定义的数据 无关 的 额外存储成本 ,考虑到 虚拟机 的 空间效率 , Mark Word 被设计成一个 非固定 的 数据结构 以便在 极小 的空间内存储 尽量多 的信息,它会根据 对象 的状态 复用 自己的 存储空间 ,例如:在 32位 的 HotSpot虚拟机 中,如果 对象 处于 未被锁定 的状态下,那么 Mark Word 的 32bit 空间中的 25bit 用于 存储对象哈希码 , 4bit 用于 存储对象分代年龄 , 2bit 用于 存储锁标志位 , 1bit 固定为 0 ,而在 其他状态(轻量级锁定、重量级锁定、GC标记、可偏向) 下 对象 的 存储内容 如下图所示:
第二部分是 类型指针 ,即 对象指向它的类元数据的指针 , 虚拟机 通过这个 指针 来确定 这个对象 是 哪个类 的 实例 。并不是所有的 虚拟机 实现都必须在 对象数据 上 保留类型指针 ,换句话说,查找 对象 的 元数据信息 并不一定要经过 对象本身 ,这点将在下面要讲的 对象的访问定位 讲解。另外,如果 对象 是一个 Java数组 ,那在 对象头 中必须有一块用于记录 数组长度 的数据,因为 虚拟机 可以通过 普通Java对象 的 元数据信息 确定 Java对象 的 大小 ,但是从 数组 的 元数据 中却 无法确定数组的大小 。
实例数据是 对象真正存储的有效信息 , 也是在程序代码中所定义的各种类型的字段内容 。无论是从 父类 继承下来的,还是在 子类 中定义的,都需要 记录 起来。这部分的 存储顺序 会受到 虚拟机分配策略参数(FieldsAllocationStyle) 和 字段在Java源码中定义顺序 的影响。 HotSpot虚拟机 默认的 分配策略 为 longs/doubles 、 ints 、 shorts/chars 、 bytes/booleans 、 oops(Ordinary Object Pointers) ,从 分配策略 中可以看出, 相同宽度的字段总是被分配到一起 。在满足这个 前提条件 的情况下,在 父类 中定义 变量 会出现在 子类 之前。如果 CompactFields 参数值为 true(默认为true) ,那么 子类 之中 较窄 的 变量 也可能会插入到 父类变量 的空隙之中。
对齐填充不是必然存在的,也没有特别的定义,它仅仅起着占位符的作用。由于 HotSpot VM 的 自动内存管理系统 要求 对象起始地址 必须是 8字节 的 整数倍 ,换句话说,就是 对象 的 大小 必须是 8字节 的 整数倍 。而 对象头 部分正好是 8字节 的 倍数(1倍或者2倍) ,因此,当 对象实例数据部分没有对齐 时,就需要通过 对齐填充 来 补全 。
建立对象是为了 使用对象 ,我们的 Java程序 需要通过 栈 上的 reference 数据来操作 堆 上的 具体对象 。由于 reference 类型在 Java虚拟机规范 中 只规定一个指向对象的引用 , 并没有定义这个引用应该通过何种方式去定位、访问堆中对象的具体位置 ,所以 对象访问方式 也是取决于 虚拟机 实现而定的。目前 主流 的 访问方式 有使用 句柄 和 直接指针 两种:
如果使用 句柄 访问的话,那么 Java堆 中将会 划分出一块内存 来作为 句柄池 , reference 中存储的就是 对象 的 句柄地址 ,而 句柄 中包含了对象 实例数据 与 类型数据 各自的 具体地址信息 ,如下图所示:
如果使用 直接指针 访问的话,那么 Java堆对象的布局 中就必须考虑如何放置 访问类型数据 的 相关信息 ,而 reference 中存储的直接就是 对象地址 ,如下图所示:
这 两种对象访问方式 各有 优势 ,使用 句柄 来访问的 最大好处 就是 reference 中存储的是 稳定的句柄地址 ,在 对象被移动(垃圾收集时移动对象是非常普遍的行为) 时 只会改变句柄中实例数据指针 ,而 reference 本身 不需要修改 。
使用 直接指针 访问方式的 最大好处 就是 速度更快 , 它节省了一次指针定位的时间开销 ,由于 对象的访问 在 Java 中 非常频繁 ,因此这里开销 积少成多 后也是一项 非常可观 的 执行成本 。本文章讨论的 虚拟机Sun HotSpot 使用的是 第二种方式 ,也就是使用 直接指针 进行 对象访问 的,但是从 整个软件开发的范围 来看,各种 语言 和 框架 使用 句柄 来进行 对象访问 也是 十分常见的 。
我想聊一下 Java基本数据类型包装类常量池 和 String类型常量池 。
Java基本数据类型中的 byte 、 short 、 int 、 long 、 boolean 、 char 的 包装类 使用了 常量池 ,它们只在**[-128, 127] 范围内使用相应 类型 的 缓存数据**, 超出这个范围 的就会 创建新的对象 ,而 float 和 double 的 包装类 没有使用 常量池 。
举个例子,代码如下所示:
/** * 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 中, == 有 两个 作用:
解析:
当声明为如上述示例代码中的 i1 、 i2 、 i3 、 i4 时, 编译器 会帮我们 自动装箱 ,调用 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 = "谭嘉俊"; 复制代码
这种 声明方式 叫做 字面量声明 ,它是把 字符串 用 双引号 包起来,然后 赋值 给一个 变量 ,这种情况下,它会把 字符串 放到 字符串常量池 ,然后返回给 变量 。
示例代码如下:
String str = new String("谭嘉俊"); 复制代码
使用 new String() 方法不管在 字符串常量池 中有没有,它都会在 堆 中 创建一个新的对象 。
源码代码如下:
// String.java public final class String implements java.io.Serializable, Comparable<String>, CharSequence { // 省略部分代码 public native String intern(); } 复制代码
可以看到 intern 方法是个 native 方法。
举个例子,代码如下:
/** * 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 } } 复制代码
我的GitHub: TanJiaJunBeyond
Android通用框架: Android通用框架
我的掘金: 谭嘉俊
我的简书: 谭嘉俊
我的CSDN: 谭嘉俊