标签:turn 存储 dep 指定 github intern connect 内存地址计算 特殊
这是博主新开的一个 java 学习系列,听名字就可以看出来,在这一些系列中,我们学习的知识点不再是蜻蜓点水,而是深入底层,深入源码。由此,学习过程中我们要带着一股钻劲儿,对我们不懂的知识充满质疑,力求把我们学过的知识点都搞清楚,想明白。
在 java 的世界里,存在一种特殊的类,它们的创建方式极为特别,不需要用到 new XXX(当然也可以用这种方式创建), 但是却大量出现在我们的代码中,那就是 String 类。作为日常中使用频率最高的类,它是那么普通,普通到我们从来都不会去思考其底层是如何工作。
今天就让我们从 String 类开始说起,慢慢揭开它神秘的面纱。
下面是 String 的继承树。
可以看到 String 类的继承树极为简单,仅仅实现了 Serializable,Comparable,CharSequence 三个接口,并且 String 类的修饰符为 final,这也就意味着字符串对象一旦被创建就无法改变。
分析:
关于 String 类实现的几个接口?
Comparable 接口只有一个 compareTo( ) 方法,用于比较两个实例化对象的大小。
CharSequence 是字符序列接口,它定义了 length( ),charAt(int index),subSequence(int start, int end) 这些方法。实现该接口是因为 String,StringBuilder 和 StringBuffer 本质上都是通过字符数组实现的。
Serializable 序列化接口,代表字符串对象可以序列化。关于 Java 序列化的更多内容,参见博客:
为什么 String 类为不可变类?
① 字符串常量池的需要
在堆内存中有一片特殊的存储区域——字符串常量池,当我们创建一个 String 对象时,会检查和此对象是否存在于常量池中。若存在,则直接返回该对象的引用;反之则新建一个字符串对象,并把该对象放入到常量池中。常量池机制通过让多个实例共享一个对象,为我们节约了大量的内存空间。此时若对象为可变的,改变该对象后,其它指向这个对象的实例也会受到影响。显然,这将造成难以预料的后果。
② 缓存哈希值的需要
每一个 String 对象的实例在内部都缓存了自己的哈希值,由于 String 是不可变类,那么一个 String 对象的哈希值一旦确定就不会变更。这个特性使得字符串很适合作为Map中的键,不仅仅在于无需重新计算键的哈希值,更在于不必担心存储节点的键的哈希值发生改变,导致再也无法找到该节点。
③ 安全性的需要
在网络编程中,主机名和端口等内容都是以字符串的形式传入。因为字符串是可变的,黑客们可以钻到空子,改变字符串指向的对象的值,造成安全漏洞。
类加载器要用到了字符串,而字符串的不可变性保证了安全性,使得正确的类被加载。比如你想加载java.sql.Connection 类,而这个值被改成了 myhacked.Connection,那么会对你的数据库造成不可知的破坏。
④ 线程安全的需要
不可变对象本身就是线程安全的,因为其不可变的特性,避免了多线程下其他线程对其修改,造成数据不同步的问题。
1 |
|
分析:
关于 final char value[ ]?
String对象中的每一个字符在底层实际上存储在 value[ ] 这个数组中的,并且它是一个final修饰的属性,所以 String 对象一旦创建即不可被修改。因此所有对字符串对象的修改(如追加字符串,删除部分字符串,截取字符串)都不是在原来的对象基础上进行,而是新建一个 String 对象修改并返回,这会造成原对象的废弃,浪费资源且性能较差(特别是追加字符串和删除部分字符串)。
若遇到字符串将被频繁修改的情况,建议不要使用 String,改用 StringBuffer 或 StringBuilder,更多介绍参见下面的分析。
String 类中的构造方法有很多,下面挑选一些常用的进行解析。
1 |
|
分析:
关于 StringBuffer 和 StringBuilder?
我们在前面说过,对字符串对象的裁剪或者添加字符的操作都不是在原字符串的基础上进行的,然后通过新建所需的字符串对象然后返回,这必然造成原对象的浪费与不必要的操作开销。
于是 java 给我们提供了 StringBuffer 和 StringBuilder。虽然它们的基本使用与 String 大致相同,但是底层的实现却有很大差异。二者均为可变的字符序列,即可以在原字符串对象的基础上进行修改。二者的区别在于 StringBuffer 是线程安全的,StringBuilder 是线程不安全的。当我们需要对字符串进行频繁修改时,效率:StringBuilder > StringBuffer > String。
因此,建议在频繁修改字符串对象的条件下使用 StringBuilder or StringBuffer 取代 String。
下面的是String 类中的公共方法,节选部分常用的作解析。
1 |
|
分析:
关于 == 和 equals( )?
① == 对于基本类型来说,是值比较;对于引用类型来说,是地址比较;
② equals( ) 是超类 Object 中规定的一个非静态的方法,只能由对象调用(基本类型无法使用),其默认实现如下:
1 | public boolean equals(Object obj) { |
可以看出,equals( ) 默认是对两个对象的地址进行比较。但是对于重写了 equals( ) 方法的子类来说,比较的内容不再是对象的地址,比如 String 类中比较的就是字符串对象的值。
关于 hashCode( ) 和 equals( )?
在 java 的世界里,为了在某些情况下对对象加以区分(比如 HashMap),在超类 Object 里面定义了 hashCode( ) 方法,默认实现如下:
1 | public native int hashCode(); |
这是一个本地方法,根据对象的内存地址计算出来的整型值。理论上来说,地址不同的两个对象,它们的哈希值肯定是不同的,也就达到了区分对象的目的。既然如此,为什么 String 类还要重写 hashCode( ) 方法呢?
我们先要了解下面一个规则:通过 equals( ) 方法判断相等的两个对象必须具有相同的哈希值!
先看下面一段代码:
1 | // 新建两个String对象,它们被分配到堆中,地址不同 |
str1 和 str2 是两个不同对象的实例(通过 new 创建的字符串分配在堆中,而不是常量池中),它们所指向的内存地址肯定不同。如果 String 类没有重写 hashCode( ) 方法,那么代码中第 4 行的结果肯定为 false。但是由于 String 类重写了 equals( ) 方法,导致第 3 行代码的结果为 true(比较的是对象的值)。这显然不符合我们之前的规则。所以 String 类必须重写 hashCode( ) 方法。
实际情况下,类中一旦重写了 equals( ) 方法,就必须重写 hashCode( ) 方法!
关于 hashCode( ) 方法中的参数 31?
为了分析的方便,再次贴出 hashCode( ) 的源码:
1 | public int hashCode() { |
不难总结出如下规律,当 n = 3 时(n 为字符串的长度):
1 | i = 0 -> h = 31 * 0 + val[0] |
上面的公式不是重点,只是为了便于下面的分析。先直接说出为什么选择 31 作为乘子的原因。
① 31 不大不小,是 hashCode 乘子的优选质数之一;
② 31 可以被 JVM 优化,31 * i = (i << 5) - i
;
第二点很容易想到,因为在计算机中,位运算比常数运算快,那么第一点是什么意思呢?为什么 31 不大不小,正合适?我们不妨取 质数 2 和 101(一个较小,一个较大),n = 6 分别带入到上面的公式中,并且仅仅取结果中次数最高的那一项。结果分别为 2^5 = 32,101^5 = 10,510,100,501。这说明什么呢?
其实,计算结果在很大程度代表了散列空间的大小。结果越小,散列空间越小,元素分布越紧密,冲突的概率就越大;空间太大则会超过 int 的表示范围(101^5 就超了),导致哈希值信息丢失。那么,让我们再来看看 31 的表现如如何:31^5 = 28629151。相较于上面的结果来说,这个算很好了。
其实,像 31 这样的优选质数还很多,比如 37、41、43 等,为什么选择 31,应该也是经过很多测试得到的结果。这里面涉及到很多数学方面的知识,就不介绍了。
笔试常考题型之判断两个字符串实例是否相等?
下面这部分内容是笔试中经常考到的,很多人都不清楚,博主也是,下面就来详细说说。
1 | String s1 = new String("abc"); |
首先需要说明的是,当我们通过字面量赋值创建字符串对象时(String s3 = “abc”),会现在常量池(jdk 1.7 位于堆中)中查找是否存在相同的字符串,若存在,则将池中的引用直接返回;反之,则在池生成一个新的字符串对象,然后将引用返回,即通过字面量赋值创建的字符串对象都处于常量池中。但是通过 new String(“abc”) 这种方式创建字符串对象时,JVM 首先在池中查找有没有 “abc” 这个字符串对象,如果有,则在堆中再创建一个 “abc” 字符串对象(里面存放着对常量池中 “abc” 的地址引用),然后将对象的地址返回;如果没有,则会在常量池中创建一个 “abc” 对象,然后在堆中创建一个字符串对象(里面存放着对常量池中 “abc” 的地址引用),最后将堆中对象的地址返回 。这也就解释了第 5,6,7 代码的运行结果。
常量字符串的 “+” 操作,在编译阶段直接会合成为一个字符串。如 String s7 = "ab" + "c"
,在编译阶段会直接合并成语句 String s7 = "abc"
,于是会去常量池中查找是否存在”abc”,从而进行创建或返回引用。当常量字符串和变进行量拼接时(如 String s8 = s5 + “c”),会调用 stringBuilder.append( ) 在堆上创建新的对象。对应13、14 行代码的运行结果。
对于 final 字段,在编译期直接进行了常量替换,String s9 = s6 + "c"
实际上相当于 String s9 = "ab" + "c"
,所以最后的结果为 true。
掌握了上面的知识,我们趁热打铁,再来说说 intern( ) 方法的作用。先看下面一段代码:
1 | String str2 = new String("str") + new String("01"); |
按照我们之前的分析,此处的结果应该为 false,但是返回的却是 true,问题就处在 str2.intern( ) 上。该方法会返回字符串对象在常量池中的引用;如果池中不存在该对象,则将该对象在堆中的引用复制到方法区中(jdk 1.7 以后),然后再返回引用。
当执行 str2.intern( ) 时,因为常量池中没有”str01”这个字符串,所以会在常量池中生成一个对堆中的”str01”的引用(注意这里是引用 ,在 jdk 1.7 之前是生成原字符串的拷贝)。在进行 String str1 = "str01"
的时候,常量池中已经存在了”str01”的引用,直接返回,所以执行结果为 true。
在这一篇博客中,我们详细介绍了 String 的底层源码,对字符串对象的创建方式以及不同创建方式下,它们在内存中的分布进行了详细的讲解,同时还普及了 hashCode( ) 和 equals( ),知道为什么重写了 equals( ) 方法,就必须重写 hashCode( ) 方法。最后,我们还知道了字符串家族中另外两枚成员:StringBuffer 或 StringBuilder 以及它们的引用场景。
死磕 Java 系列(一)—— 常用类(1) String 源码解析
标签:turn 存储 dep 指定 github intern connect 内存地址计算 特殊
原文地址:https://www.cnblogs.com/sanxiandoupi/p/11699425.html