提交 77bc0a1e 编写于 作者: 沉默王二's avatar 沉默王二 💬

2021年06月05日更新

上级 024066e4
......@@ -15,16 +15,10 @@
<br>
# 前言
同学们好,我是二哥呀,欢迎来到《教妹学 Java》专栏。
[我妹今年上大学了](https://mp.weixin.qq.com/s/bsu9uH8VKh5Vtue-9SafwQ),学的计算机编程,立志像我一样做一名正儿八经的 Java 程序员。我期初是反抗的,因为程序员这行业容易掉头发,作为一名需要美貌的女生,长发飘飘是必须的啊。但与其反抗,不如做点更积极的事情,比如说写点有趣的文章,教她更快地掌握 Java 这门编程语言,于是就有了这个专栏。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/tech-sister-01.png)
......@@ -59,12 +53,31 @@
- [代码初始化块](docs/object-class/code-init.md)
- [final 关键字](docs/object-class/java-final.md)
- [instanceof](docs/object-class/java-instanceof.md)
- [抽象类](docs/object-class/java-abstract.md)
- [接口](docs/object-class/java-interface.md)
- [值传递与引用传递](docs/object-class/pass-by-value.md)
- [浅拷贝与深拷贝](docs/object-class/deep-copy.md)
- [自动拆箱与装箱](docs/object-class/box.md)
## **数组**
- [数组概览](docs/array/gailan.md)
- [Arrays](docs/array/arrays.md)
- [打印数组](docs/array/print.md)
## **字符串**
- [字符串源码分析](docs/string/source.md)
- [字符串常量池](docs/string/constant-pool.md)
- [intern](docs/string/intern.md)
## **字符串**
# 公众号
如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号“**沉默王二**”。
**《教妹学Java》:** 本文档的离线版 PDF 版可以扫描下方的二维码关注我的[公众号](#公众号)后回复 **" 03"** 关键字即可领取!
**《教妹学Java》:** 本文档的离线版 PDF 版可以扫描下方的二维码关注我的[公众号](#公众号)后回复 **"03"** 关键字即可领取!
<div align="center">
......@@ -73,7 +86,7 @@
# Donate
开源不易,如果《教妹学 Java》专栏对你有些帮助,可以请作者喝杯咖啡,让他继续肝!
开源不易,如果《教妹学 Java》专栏对你有些帮助,可以请二哥喝杯咖啡,让他继续肝!
<div align="center">
......
## 数组专用工具类
“哥,数组专用工具类是专门用来操作数组的吗?比如说创建数组、数组排序、数组检索等等。”三妹的提问其实已经把答案说了出来。
“是滴,这里说的数组专用工具类指的是 `java.util.Arrays` 类,基本上常见的数组操作,这个类都提供了静态方法可供直接调用。毕竟数组本身想完成这些操作还是挺麻烦的,有了这层封装,就方便多了。”在回答三妹的同时,我打开 Intellij IDEA,找到了 Arrays 类的源码。
```java
package java.util;
/**
* @author Josh Bloch
* @author Neal Gafter
* @author John Rose
* @since 1.2
*/
public class Arrays {}
```
“具体来说,数组操作可分为以下 9 种。”
- 创建数组
- 比较数组
- 数组排序
- 数组检索
- 数组转流
- 打印数组
- 数组转 List
- setAll(没想好中文名)
- parallelPrefix(没想好中文名)
“我们来一个一个学习。”
### 01、创建数组
使用 Arrays 类创建数组可以通过以下三个方法:
- copyOf,复制指定的数组,截取或用 null 填充
- copyOfRange,复制指定范围内的数组到一个新的数组
- fill,对数组进行填充
1)copyOf,直接来看例子:
```java
String[] intro = new String[] { "沉", "默", "王", "二" };
String[] revised = Arrays.copyOf(intro, 3);
String[] expanded = Arrays.copyOf(intro, 5);
System.out.println(Arrays.toString(revised));
System.out.println(Arrays.toString(expanded));
```
revised 和 expanded 是复制后的新数组,长度分别是 3 和 5,指定的数组长度是 4。来看一下输出结果:
```
[沉, 默, 王]
[沉, 默, 王, 二, null]
```
看到没?revised 截取了最后一位,因为长度是 3 嘛;expanded 用 null 填充了一位,因为长度是 5。
ArrayList(内部的数据结构用的就是数组)源码中的 `grow()` 方法就调用了 `copyOf()` 方法:当 ArrayList 初始大小不满足元素的增长时就会扩容。
```java
private Object[] grow(int minCapacity) {
return elementData = Arrays.copyOf(elementData,
newCapacity(minCapacity));
}
```
2)copyOfRange,直接来看例子:
```java
String[] intro = new String[] { "沉", "默", "王", "二" };
String[] abridgement = Arrays.copyOfRange(intro, 0, 3);
System.out.println(Arrays.toString(abridgement));
```
`copyOfRange()` 方法需要三个参数,第一个是指定的数组,第二个是起始位置(包含),第三个是截止位置(不包含)。来看一下输出结果:
```java
[, , ]
```
0 的位置是“沉”,3 的位置是“二”,也就是说截取了从 0 位(包含)到 3 位(不包含)的数组元素。那假如说下标超出了数组的长度,会发生什么呢?
```java
String[] abridgementExpanded = Arrays.copyOfRange(intro, 0, 6);
System.out.println(Arrays.toString(abridgementExpanded));
```
结束位置此时为 6,超出了指定数组的长度 4,来看一下输出结果:
```
[沉, 默, 王, 二, null, null]
```
仍然使用了 null 进行填充。
“为什么要这么做呢?”经过这段时间的学习,三妹的眼光越来越毒辣了,问的问题都恰到好处。
“嗯,我想是 Arrays 的设计者考虑到了数组越界的问题,不然每次调用 Arrays 类就要先判断很多次长度,很麻烦。”稍作思考后,我给出了这样一个回答。
3)fill,直接来看例子:
```java
String[] stutter = new String[4];
Arrays.fill(stutter, "沉默王二");
System.out.println(Arrays.toString(stutter));
```
使用 new 关键字创建了一个长度为 4 的数组,然后使用 `fill()` 方法将 4 个位置填充为“沉默王二”,来看一下输出结果:
```
[沉默王二, 沉默王二, 沉默王二, 沉默王二]
```
如果想要一个元素完全相同的数组时, `fill()` 方法就派上用场了。
### 02、比较数组
Arrays 类的 `equals()` 方法用来判断两个数组是否相等,来看下面这个例子:
```java
String[] intro = new String[] { "沉", "默", "王", "二" };
boolean result = Arrays.equals(new String[] { "沉", "默", "王", "二" }, intro);
System.out.println(result);
boolean result1 = Arrays.equals(new String[] { "沉", "默", "王", "三" }, intro);
System.out.println(result1);
```
输出结果如下所示:
```
true
false
```
指定的数组为沉默王二四个字,比较的数组一个是沉默王二,一个是沉默王三,所以 result 为 true,result1 为 false。
简单看一下 `equals()` 方法的源码:
```java
public static boolean equals(Object[] a, Object[] a2) {
if (a==a2)
return true;
if (a==null || a2==null)
return false;
int length = a.length;
if (a2.length != length)
return false;
for (int i=0; i<length; i++) {
if (!Objects.equals(a[i], a2[i]))
return false;
}
return true;
}
```
因为数组是一个对象,所以先使用“==”操作符进行判断,如果不相等,再判断是否为 null,两个都为 null,返回 false;紧接着判断 length,不等的话,返回 false;否则的话,依次调用 `Objects.equals()` 比较相同位置上的元素是否相等。
“这段代码还是非常严谨的,对吧?三妹,这也就是我们学习源码的意义,欣赏的同时,可以学习源码作者清晰的编码思路。”我语重心长地给三妹讲。
除了 `equals()` 方法,还有另外一个诀窍可以判断两个数组是否相等,尽管可能会出现误差。那就是 `Arrays.hashCode()` 方法,先来看一下该方法的源码:
```java
public static int hashCode(Object a[]) {
if (a == null)
return 0;
int result = 1;
for (Object element : a)
result = 31 * result + (element == null ? 0 : element.hashCode());
return result;
}
```
哈希算法本身是非常严谨的,所以如果两个数组的哈希值相等,那几乎可以判断两个数组是相等的。
```java
String[] intro = new String[] { "沉", "默", "王", "二" };
System.out.println(Arrays.hashCode(intro));
System.out.println(Arrays.hashCode(new String[] { "沉", "默", "王", "二" }));
```
来看一下输出结果:
```
868681617
868681617
```
两个数组的哈希值相等,毕竟元素是一样的。但这样确实不够严谨,优先使用 `Objects.equals()` 方法,当我们想快速确认两个数组是否相等时,可以通过比较 hashCode 来确认——算是投机取巧吧,高收益高风险,哈哈。
### 03、数组排序
Arrays 类的 `sort()` 方法用来对数组进行排序,来看下面这个例子:
```java
String[] intro1 = new String[] { "chen", "mo", "wang", "er" };
String[] sorted = Arrays.copyOf(intro1, 4);
Arrays.sort(sorted);
System.out.println(Arrays.toString(sorted));
```
由于排序会改变原有的数组,所以我们使用了 `copyOf()` 方法重新复制了一份。来看一下输出结果:
```
[chen, er, mo, wang]
```
可以看得出,按照的是首字母的升序进行排列的。基本数据类型是按照双轴快速排序的,引用数据类型是按照 TimSort 排序的,使用了 Peter McIlroy 的“乐观排序和信息理论复杂性”中的技术。
“哥,你说的这些排序算法我都不太懂啊!”三妹眨巴眨巴眼睛说。
“不要紧的,后面学了数据结构与算法后,就明白了,现在了解这个东西即可。”我赶紧甩出了安抚大法。
### 04、数组检索
数组排序后就可以使用 Arrays 类的 `binarySearch()` 方法进行二分查找了。否则的话,只能线性检索,效率就会低很多。
```java
String[] intro1 = new String[] { "chen", "mo", "wang", "er" };
String[] sorted = Arrays.copyOf(intro1, 4);
Arrays.sort(sorted);
int exact = Arrays.binarySearch(sorted, "wang");
System.out.println(exact);
int caseInsensitive = Arrays.binarySearch(sorted, "Wang", String::compareToIgnoreCase);
System.out.println(caseInsensitive);
```
`binarySearch()` 方法既可以精确检索,也可以模糊检索,比如说忽略大小写。来看一下输出结果:
```
3
3
```
排序后的结果是 `[chen, er, mo, wang]`,所以检索出来的下标是 3。
“三妹,记住了,以后如果要从数组或者集合中查找元素的话,尽量先排序,然后使用二分查找法,这样能提高检索的效率。”
三妹若有所思的点了点头。
### 05、数组转流
“流是什么呀?”三妹好奇的问。
“流的英文单词是 Stream,它可以极大提高 Java 程序员的生产力,让程序员写出高效、干净、简洁的代码。 这种风格将要处理的集合看作是一种流,想象一下水流在管道中流过的样子,我们可以在管道中对流进行处理,比如筛选、排序等等。Stream 具体怎么使用,我们留到后面再详细地讲,这里你先有一个大致的印象就可以了。”我回答到。
Arrays 类的 `stream()` 方法可以将数组转换成流:
```java
String[] intro = new String[] { "沉", "默", "王", "二" };
System.out.println(Arrays.stream(intro).count());
```
还可以为 `stream()` 方法指定起始下标和结束下标:
```java
System.out.println(Arrays.stream(intro, 1, 2).count());
```
如果下标的范围有误的时候,比如说从 2 到 1 结束,则程序会抛出 ArrayIndexOutOfBoundsException 异常:
```
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: origin(2) > fence(1)
at java.base/java.util.Spliterators.checkFromToBounds(Spliterators.java:387)
```
### 06、打印数组
因为数组是一个对象,直接 `System.out.println` 的话,结果是这样的:
```
[Ljava.lang.String;@3d075dc0
```
最优雅的打印方式,是使用 `Arrays.toString()`,来看一下该方法的源码:
```java
public static String toString(Object[] a) {
if (a == null)
return "null";
int iMax = a.length - 1;
if (iMax == -1)
return "[]";
StringBuilder b = new StringBuilder();
b.append('[');
for (int i = 0; ; i++) {
b.append(String.valueOf(a[i]));
if (i == iMax)
return b.append(']').toString();
b.append(", ");
}
}
```
- 先判断 null,是的话,直接返回“null”字符串;
- 获取数组的长度,如果数组的长度为 0( 等价于 length - 1 为 -1),返回中括号“[]”,表示数组为空的;
- 如果数组既不是 null,长度也不为 0,就声明 StringBuilder 对象,然后添加一个数组的开始标记“[”,之后再遍历数组,把每个元素添加进去;其中一个小技巧就是,当遇到末尾元素的时候(i == iMax),不再添加逗号和空格“, ”,而是添加数组的闭合标记“]”。
“哥,我能不能问一个问题呀?”
“你问啊。”
“就是为什么判断数组长度为 0 的时候判断的是减 1 后比较 -1 呢?为什么不直接比较 0 呢?”
“呀,你这个问题问的很妙啊!”我想到三妹说一句“respect”,很强!“其实是和遍历数组的时候判断 `i == iMax` 有关了,否则这里就要用 `i == iMax -1` 来判断是否到达数组的最后一个元素了。”
“哦----------”三妹似乎明白了什么。
### 07、数组转 List
尽管数组非常强大,但它自身可以操作的工具方法很少,比如说判断数组中是否包含某个值。如果能转成 List 的话,就简便多了,因为 Java 的集合框架 List 中封装了很多常用的方法。
```java
String[] intro = new String[] { "沉", "默", "王", "二" };
List<String> rets = Arrays.asList(intro);
System.out.println(rets.contains("二"));
```
不过需要注意的是,`Arrays.asList()` 返回的是 `java.util.Arrays.ArrayList`,并不是 `java.util.ArrayList`,它的长度是固定的,无法进行元素的删除或者添加。
```java
rets.add("三");
rets.remove("二");
```
这个在编码的时候一定要注意,否则在执行这两个方法的时候,会抛出异常:
```
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.AbstractList.add(AbstractList.java:153)
at java.base/java.util.AbstractList.add(AbstractList.java:111)
```
要想操作元素的话,需要多一步转化,转成真正的 `java.util.ArrayList`
```java
List<String> rets1 = new ArrayList<>(Arrays.asList(intro));
rets1.add("三");
rets1.remove("二");
```
### 08、setAll
Java 8 新增了 `setAll()` 方法,它提供了一个函数式编程的入口,可以对数组的元素进行填充:
```java
int[] array = new int[10];
Arrays.setAll(array, i -> i * 10);
System.out.println(Arrays.toString(array));
```
“这段代码什么意思呢?”三妹问。
i 就相当于是数组的下标,值从 0 开始,到 9 结束,那么 `i * 10` 就意味着值从 0 * 10 开始,到 9 * 10 结束,来看一下输出结果:
```
[0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
```
可以用来为新数组填充基于原来数组的新元素。
### 09、parallelPrefix
`parallelPrefix()` 方法和 `setAll()` 方法一样,也是 Java 8 之后提供的,提供了一个函数式编程的入口,通过遍历数组中的元素,将当前下标位置上的元素与它之前下标的元素进行操作,然后将操作后的结果覆盖当前下标位置上的元素。
```java
int[] arr = new int[] { 1, 2, 3, 4};
Arrays.parallelPrefix(arr, (left, right) -> left + right);
System.out.println(Arrays.toString(arr));
```
上面代码中有一个 Lambda 表达式(`(left, right) -> left + right`),是什么意思呢?上面这段代码等同于:
```java
int[] arr = new int[]{1, 2, 3, 4};
Arrays.parallelPrefix(arr, (left, right) -> {
System.out.println(left + "," + right);
return left + right;
});
System.out.println(Arrays.toString(arr));
```
来看一下输出结果就明白了:
```
1,2
3,3
6,4
[1, 3, 6, 10]
```
也就是说, Lambda 表达式执行了三次:
- 第一次是 1 和 2 相加,结果是 3,替换下标为 1 的位置
- 第二次是 3 和 3 相加,结果是 6,也就是第一次的结果和下标为 2 的元素相加的结果
- 第三次是 6 和 4 相加,结果是 10,也就是第二次的结果和下标为 3 的元素相加的结果
### 10、总结
“好了,三妹,就先学到这吧。如果你以后翻 Java 源码的时候,只要是用到数组的,尤其是 ArrayList 类,就可以看到 Arrays 类的很多影子。”
“嗯嗯,我先复习一下这节的内容。哥,你去休息吧。”
我来到客厅,坐到沙发上,捧起黄永玉先生的《无愁河上的浪荡汉子·八年卷 1》看了起来,津津有味。。。。。。
\ No newline at end of file
## 数组概览
“哥,我看你之前的文章里提到,ArrayList 的内部是用数组实现的,我就对数组非常感兴趣,想深入地了解一下,今天终于到这个环节了,好期待呀!”三妹的语气里显得很兴奋。
“的确是的,看 ArrayList 的源码就一清二楚了。”我一边说,一边打开 Intellij IDEA,并找到了 ArrayList 的源码。
```java
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/
transient Object[] elementData; // non-private to simplify nested class access
/**
* The size of the ArrayList (the number of elements it contains).
*
* @serial
*/
private int size;
```
“瞧见没?`Object[] elementData` 就是数组。”我指着显示屏上这串代码继续说。
数组是一个对象,它包含了一组固定数量的元素,并且这些元素的类型是相同的。数组会按照索引的方式将元素放在指定的位置上,意味着我们可以通过索引来访问这些元素。在 Java 中,索引是从 0 开始的。
“哥,能说一下为什么索引从 0 开始吗?”三妹突然这个话题很感兴趣。
“哦,Java 是基于 C/C++ 语言实现的,而 C 语言的下标是从 0 开始的,所以 Java 就继承了这个良好的传统习惯。C语言有一个很重要概念,叫做指针,它实际上是一个偏移量,距离开始位置的偏移量,第一个元素就在开始的位置,它的偏移量就为 0,所以索引就为 0。”此刻,我很自信。
“此外,还有另外一种说法。早期的计算机资源比较匮乏,0 作为起始下标相比较于 1 作为起始下标,编译的效率更高。”
“哦。”三妹意味深长地点了点头。
我们可以将数组理解为一个个整齐排列的单元格,每个单元格里面存放着一个元素。
数组元素的类型可以是基本数据类型(比如说 int、double),也可以是引用数据类型(比如说 String),包括自定义类型。
数组的声明方式分两种。
先来看第一种:
```java
int[] anArray;
```
再来看第二种:
```java
int anOtherArray[];
```
不同之处就在于中括号的位置,是跟在类型关键字的后面,还是跟在变量的名称的后面。前一种的使用频率更高一些,像 ArrayList 的源码中就用了第一种方式。
同样的,数组的初始化方式也有多种,最常见的是:
```java
int[] anArray = new int[10];
```
看到了没?上面这行代码中使用了 new 关键字,这就意味着数组的确是一个对象,只有对象的创建才会用到 new 关键字,基本数据类型是不用的。然后,我们需要在方括号中指定数组的长度。
这时候,数组中的每个元素都会被初始化为默认值,int 类型的就为 0,Object 类型的就为 null。 不同数据类型的默认值不同,可以参照[之前的文章](https://mp.weixin.qq.com/s/twim3w_dp5ctCigjLGIbFw)
另外,还可以使用大括号的方式,直接初始化数组中的元素:
```java
int anOtherArray[] = new int[] {1, 2, 3, 4, 5};
```
这时候,数组的元素分别是 1、2、3、4、5,索引依次是 0、1、2、3、4,长度是 5。
“哥,怎么访问数组呢?”三妹及时地插话到。
前面提到过,可以通过索引来访问数组的元素,就像下面这样:
```java
anArray[0] = 10;
```
变量名,加上中括号,加上元素的索引,就可以访问到数组,通过“=”操作符可以对元素进行赋值。
如果索引的值超出了数组的界限,就会抛出 `ArrayIndexOutOfBoundException`
既然数组的索引是从 0 开始,那就是到数组的 `length - 1` 结束,不要使用超出这个范围内的索引访问数组,就不会抛出数组越界的异常了。
当数组的元素非常多的时候,逐个访问数组就太辛苦了,所以需要通过遍历的方式。
第一种,使用 for 循环:
```java
int anOtherArray[] = new int[] {1, 2, 3, 4, 5};
for (int i = 0; i < anOtherArray.length; i++) {
System.out.println(anOtherArray[i]);
}
```
通过 length 属性获取到数组的长度,然后从 0 开始遍历,就得到了数组的所有元素。
第二种,使用 for-each 循环:
```java
for (int element : anOtherArray) {
System.out.println(element);
}
```
如果不需要关心索引的话(意味着不需要修改数组的某个元素),使用 for-each 遍历更简洁一些。当然,也可以使用 while 和 do-while 循环。
在 Java 中,可变参数用于将任意数量的参数传递给方法,来看 `varargsMethod()` 方法:
```java
void varargsMethod(String... varargs) {}
```
该方法可以接收任意数量的字符串参数,可以是 0 个或者 N 个,本质上,可变参数就是通过数组实现的。为了证明这一点,我们可以看一下反编译一后的字节码:
```java
public class VarargsDemo
{
public VarargsDemo()
{
}
transient void varargsMethod(String as[])
{
}
}
```
所以,我们其实可以直接将数组作为参数传递给该方法:
```java
VarargsDemo demo = new VarargsDemo();
String[] anArray = new String[] {"沉默王二", "一枚有趣的程序员"};
demo.varargsMethod(anArray);
```
也可以直接传递多个字符串,通过逗号隔开的方式:
```java
demo.varargsMethod("沉默王二", "一枚有趣的程序员");
```
在 Java 中,数组与 List 关系非常密切。List 封装了很多常用的方法,方便我们对集合进行一些操作,而如果直接操作数组的话,有很多不便,因为数组本身没有提供这些封装好的操作,所以有时候我们需要把数组转成 List。
“怎么转呢?”三妹问到。
最原始的方式,就是通过遍历数组的方式,一个个将数组添加到 List 中。
```java
int[] anArray = new int[] {1, 2, 3, 4, 5};
List<Integer> aList = new ArrayList<>();
for (int element : anArray) {
aList.add(element);
}
```
更优雅的方式是通过 Arrays 类的 `asList()` 方法:
```java
List<Integer> aList = Arrays.asList(anArray);
```
但需要注意的是,该方法返回的 ArrayList 并不是 `java.util.ArrayList`,它其实是 Arrays 类的一个内部类:
```java
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable{}
```
如果需要添加元素或者删除元素的话,需要把它转成 `java.util.ArrayList`
```java
new ArrayList<>(Arrays.asList(anArray));
```
Java 8 新增了 Stream 流的概念,这就意味着我们也可以将数组转成 Stream 进行操作。
```java
String[] anArray = new String[] {"沉默王二", "一枚有趣的程序员", "好好珍重他"};
Stream<String> aStream = Arrays.stream(anArray);
```
如果想对数组进行排序的话,可以使用 Arrays 类提供的 `sort()` 方法。
- 基本数据类型按照升序排列
- 实现了 Comparable 接口的对象按照 `compareTo()` 的排序
来看第一个例子:
```java
int[] anArray = new int[] {5, 2, 1, 4, 8};
Arrays.sort(anArray);
```
排序后的结果如下所示:
```java
[1, 2, 4, 5, 8]
```
来看第二个例子:
```java
String[] yetAnotherArray = new String[] {"A", "E", "Z", "B", "C"};
Arrays.sort(yetAnotherArray, 1, 3,
Comparator.comparing(String::toString).reversed());
```
只对 1-3 位置上的元素进行反序,所以结果如下所示:
```
[A, Z, E, B, C]
```
有时候,我们需要从数组中查找某个具体的元素,最直接的方式就是通过遍历的方式:
```java
int[] anArray = new int[] {5, 2, 1, 4, 8};
for (int i = 0; i < anArray.length; i++) {
if (anArray[i] == 4) {
System.out.println("找到了 " + i);
break;
}
}
```
上例中从数组中查询元素 4,找到后通过 break 关键字退出循环。
如果数组提前进行了排序,就可以使用二分查找法,这样效率就会更高一些。`Arrays.binarySearch()` 方法可供我们使用,它需要传递一个数组,和要查找的元素。
```java
int[] anArray = new int[] {1, 2, 3, 4, 5};
int index = Arrays.binarySearch(anArray, 4);
```
“除了一维数组,还有二维数组,三妹你可以去研究下,比如说用二维数组打印一下杨辉三角。”说完,我就去阳台上休息了,留三妹在那里学习,不能打扰她。
-------
**点赞越多,更新的动力越足哟,疯狂暗示**~
\ No newline at end of file
## 打印数组
“哥,之前听你说,数组也是一个对象,但 Java 中并未明确的定义这样一个类。”看来三妹有在用心地学习。
“是的,因此数组也就没有机会覆盖 `Object.toString()` 方法。如果尝试直接打印数组的话,输出的结果并不是我们预期的结果。”我接着三妹的话继续说。
“那怎么打印数组呢?”三妹心有灵犀地把今天的核心问题提了出来。
“首先,我们来看一下,为什么不能直接打印数组,直接打印的话,会出现什么问题。”
来看这样一个例子。
```
String [] cmowers = {"沉默","王二","一枚有趣的程序员"};
System.out.println(cmowers);
```
程序打印的结果是:
```
[Ljava.lang.String;@3d075dc0
```
`[Ljava.lang.String;` 表示字符串数组的 Class 名,@ 后面的是十六进制的 hashCode——这样的打印结果太“人性化”了,一般人表示看不懂!为什么会这样显示呢?查看一下 `java.lang.Object` 类的 `toString()` 方法就明白了。
```java
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
```
再次证明,数组虽然没有显式定义成一个类,但它的确是一个对象,继承了祖先类 Object 的所有方法。
“哥,那为什么数组不单独定义一个类来表示呢?就像字符串 String 类那样呢?”三妹这个问题让人头大,但也好解释。
“一个合理的说法是 Java 将其隐藏了。假如真的存在这么一个类,就叫 Array.java 吧,我们假想一下它真实的样子,必须得有一个容器来存放数组的每一个元素,就像 String 类那样。”一边回答三妹,我一边打开了 String 类的源码。
```java
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
}
```
“最终还是要用类似一种数组的形式来存放数组的元素,对吧?这就变得很没有必要了,不妨就把数组当做是一个没有形体的对象吧!”
“好了,不讨论这个了。”我怕话题扯远了,扯到我自己也答不出来就尴尬了,赶紧把三妹的思路拽了回来。
“我们来看第一种打印数组的方法,使用时髦一点的 Stream 流。”
第一种形式:
```java
Arrays.asList(cmowers).stream().forEach(s -> System.out.println(s));
```
第二种形式:
```java
Stream.of(cmowers).forEach(System.out::println);
```
第三种形式:
```java
Arrays.stream(cmowers).forEach(System.out::println);
```
打印的结果如下所示。
```
沉默
王二
一枚有趣的程序员
```
没错,这三种方式都可以轻松胜任本职工作,并且显得有点高大上,毕竟用到了 Stream,以及 lambda 表达式。
“当然了,也可以使用比较土的方式,for 循环。甚至 for-each 也行。”
```java
for(int i = 0; i < cmowers.length; i++){
System.out.println(cmowers[i]);
}
for (String s : cmowers) {
System.out.println(s);
}
```
“哥,你难道忘了[上一篇](https://mp.weixin.qq.com/s/acnDNH6A8USm_EYIT6i-jA)在讲 Arrays 工具类的时候,提到过另外一种方法 `Arrays.toString()` 吗?”三妹看我一直说不到点子上,有点着急了。
“当然没有了,我认为 `Arrays.toString()` 是打印数组的最佳方式,没有之一。”我的情绪有点激动。
`Arrays.toString()` 可以将任意类型的数组转成字符串,包括基本类型数组和引用类型数组。该方法有多种重载形式。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/array/print-01.png)
使用 `Arrays.toString()` 方法来打印数组再优雅不过了,就像,就像,就像蒙娜丽莎的微笑。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/array/print-02.png)
(三妹看到这么一副图的时候忍不住地笑了)
“三妹,你不要笑,来,怀揣着愉快的心情看一下代码示例。”
```java
String [] cmowers = {"沉默","王二","一枚有趣的程序员"};
System.out.println(Arrays.toString(cmowers));
```
程序打印结果:
```
[沉默, 王二, 一枚有趣的程序员]
```
哇,打印格式不要太完美,不多不少!完全是我们预期的结果:`[]` 表明是一个数组,`,` 点和空格用来分割元素。
“哥,那如果我想打印二维数组呢?”
“可以使用 `Arrays.deepToString()` 方法。”
```java
String[][] deepArray = new String[][] {{"沉默", "王二"}, {"一枚有趣的程序员"}};
System.out.println(Arrays.deepToString(deepArray));
```
打印结果如下所示。
```
[[沉默, 王二], [一枚有趣的程序员]]
```
-------
“说到打印,三妹,哥给你提醒一点。阿里巴巴的 Java 开发手册上有这样一条规约,你看。”
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/array/print-03.png)
“什么是 POJO 呢,就是 Plain Ordinary Java Object 的缩写,一般在 Web 应用程序中建立一个数据库的映射对象时,我们称它为 POJO,这类对象不继承或不实现任何其它 Java 框架的类或接口。”
“对于这样的类,最好是重写一下它的 `toString()` 方法,方便查看这个对象到底包含了什么字段,好排查问题。”
“如果不重写的话,打印出来的 Java 对象就像直接打印数组的那样,一串谁也看不懂的字符序列。”
“可以借助 Intellij IDEA 生成重写的 `toString()` 方法,特别方便。”
“好的,哥,我记住了。以后遇到的话,我注意下。你去休息吧,我来敲一下你提到的这些代码,练一练。”
“OK,我走,我走。”
## 自动装箱与拆箱
“哥,听说 Java 的每个基本类型都对应了一个包装类型,比如说 int 的包装类型为 Integer,double 的包装类型为 Double,是这样吗?”从三妹这句话当中,能听得出来,她已经提前预习这块内容了。
“是的,三妹。基本类型和包装类型的区别主要有以下 4 点,我来带你学习一下。”我回答说。我们家的斜对面刚好是一所小学,所以时不时还能听到朗朗的读书声,让人心情非常愉快。
“三妹,你准备好了吗?我们开始吧。”
“第一,**包装类型可以为 null,而基本类型不可以**。别小看这一点区别,它使得包装类型可以应用于 POJO 中,而基本类型则不行。”
“POJO 是什么呢?”遇到不会的就问,三妹在这一点上还是非常兢兢业业的。
“POJO 的英文全称是 Plain Ordinary Java Object,翻译一下就是,简单无规则的 Java 对象,只有字段以及对应的 setter 和 getter 方法。”
```java
class Writer {
private Integer age;
private String name;
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
```
和 POJO 类似的,还有数据传输对象 DTO(Data Transfer Object,泛指用于展示层与服务层之间的数据传输对象)、视图对象 VO(View Object,把某个页面的数据封装起来)、持久化对象 PO(Persistant Object,可以看成是与数据库中的表映射的 Java 对象)。
“那为什么 POJO 的字段必须要用包装类型呢?”三妹问。
“《阿里巴巴 Java 开发手册》上有详细的说明,你看。”我打开 PDF,并翻到了对应的内容,指着屏幕念道。
>数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱,就会抛出 NullPointerException 的异常。
“什么是自动拆箱呢?”
“自动拆箱指的是,将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值;对应的,把基本类型转为包装类型,则称为自动装箱。”
“哦。”
“那接下来,我们来看第二点不同。**包装类型可用于泛型,而基本类型不可以**,否则就会出现编译错误。”一边说着,我一边在 Intellij IDEA 中噼里啪啦地敲了起来。
“三妹,你瞧,编译器提示错误了。”
```java
List<int> list = new ArrayList<>(); // 提示 Syntax error, insert "Dimensions" to complete ReferenceType
List<Integer> list = new ArrayList<>();
```
“为什么呢?”三妹及时地问道。
“因为泛型在编译时会进行类型擦除,最后只保留原始类型,而原始类型只能是 Object 类及其子类——基本类型是个例外。”
“那,接下来,我们来说第三点,**基本类型比包装类型更高效**。”我喝了一口茶继续说道。
“作为局部变量时,基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。”我一边说着,一边打开 `draw.io` 画起了图。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/box-01.png)
很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间,不仅要存储对象,还要存储引用。假如没有基本类型的话,对于数值这类经常使用到的数据来说,每次都要通过 new 一个包装类型就显得非常笨重。
“三妹,你想知道程序运行时,数据都存储在什么地方吗?”
“嗯嗯,哥,你说说呗。”
“通常来说,有 4 个地方可以用来存储数据。”
1)寄存器。这是最快的存储区,因为它位于 CPU 内部,用来暂时存放参与运算的数据和运算结果。
2)栈。位于 RAM(Random Access Memory,也叫主存,与 CPU 直接交换数据的内部存储器)中,速度仅次于寄存器。但是,在分配内存的时候,存放在栈中的数据大小与生存周期必须在编译时是确定的,缺乏灵活性。基本数据类型的值和对象的引用通常存储在这块区域。
3)堆。也位于 RAM 区,可以动态分配内存大小,编译器不必知道要从堆里分配多少存储空间,生存周期也不必事先告诉编译器,Java 的垃圾收集器会自动收走不再使用的数据,因此可以得到更大的灵活性。但是,运行时动态分配内存和销毁对象都需要占用时间,所以效率比栈低一些。new 创建的对象都会存储在这块区域。
4)磁盘。如果数据完全存储在程序之外,就可以不受程序的限制,在程序没有运行时也可以存在。像文件、数据库,就是通过持久化的方式,让对象存放在磁盘上。当需要的时候,再反序列化成程序可以识别的对象。
“能明白吗?三妹?”
“这节讲完后,我再好好消化一下。”
“那好,我们来说第四点,**两个包装类型的值可以相同,但却不相等**。”
```java
Integer chenmo = new Integer(10);
Integer wanger = new Integer(10);
System.out.println(chenmo == wanger); // false
System.out.println(chenmo.equals(wanger )); // true
```
“两个包装类型在使用“==”进行判断的时候,判断的是其指向的地址是否相等,由于是两个对象,所以地址是不同的。”
“而 chenmo.equals(wanger) 的输出结果为 true,是因为 equals() 方法内部比较的是两个 int 值是否相等。”
```java
private final int value;
public int intValue() {
return value;
}
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
```
虽然 chenmo 和 wanger 的值都是 10,但他们并不相等。换句话说就是:将“==”操作符应用于包装类型比较的时候,其结果很可能会和预期的不符。
“三妹,瞧,`((Integer)obj).intValue()` 这段代码就是用来自动拆箱的。下面,我们来详细地说一说自动装箱和自动拆箱。”
既然有基本类型和包装类型,肯定有些时候要在它们之间进行转换。把基本类型转换成包装类型的过程叫做装箱(boxing)。反之,把包装类型转换成基本类型的过程叫做拆箱(unboxing)。
在 Java 1.5 之前,开发人员要手动进行装拆箱,比如说:
```java
Integer chenmo = new Integer(10); // 手动装箱
int wanger = chenmo.intValue(); // 手动拆箱
```
Java 1.5 为了减少开发人员的工作,提供了自动装箱与自动拆箱的功能。这下就方便了。
```jav
Integer chenmo = 10; // 自动装箱
int wanger = chenmo; // 自动拆箱
```
来看一下反编译后的代码。
```java
Integer chenmo = Integer.valueOf(10);
int wanger = chenmo.intValue();
```
也就是说,自动装箱是通过 `Integer.valueOf()` 完成的;自动拆箱是通过 `Integer.intValue()` 完成的。
“嗯,三妹,给你出一道面试题吧。”
```java
// 1)基本类型和包装类型
int a = 100;
Integer b = 100;
System.out.println(a == b);
// 2)两个包装类型
Integer c = 100;
Integer d = 100;
System.out.println(c == d);
// 3)
c = 200;
d = 200;
System.out.println(c == d);
```
“给你 3 分钟时间,你先思考下,我去抽根华子,等我回来,然后再来分析一下为什么。”
。。。。。。
“嗯,哥,你过来吧,我说一说我的想法。”
第一段代码,基本类型和包装类型进行 == 比较,这时候 b 会自动拆箱,直接和 a 比较值,所以结果为 true。
第二段代码,两个包装类型都被赋值为了 100,这时候会进行自动装箱,按照你之前说的,将“==”操作符应用于包装类型比较的时候,其结果很可能会和预期的不符,我想结果可能为 false。
第三段代码,两个包装类型重新被赋值为了 200,这时候仍然会进行自动装箱,我想结果仍然为 false。
“嗯嗯,三妹,你分析的很有逻辑,但第二段代码的结果为 true,是不是感到很奇怪?”
“为什么会这样呀?”三妹急切地问。
“你说的没错,自动装箱是通过 Integer.valueOf() 完成的,我们来看看这个方法的源码就明白为什么了。”
```java
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
```
是不是看到了一个之前从来没见过的类——IntegerCache?
“难道说是 Integer 的缓存类?”三妹做出了自己的判断。
“是的,来看一下 IntegerCache 的源码吧。”
```java
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
}
```
大致瞟一下这段代码你就全明白了。-128 到 127 之间的数会从 IntegerCache 中取,然后比较,所以第二段代码(100 在这个范围之内)的结果是 true,而第三段代码(200 不在这个范围之内,所以 new 出来了两个 Integer 对象)的结果是 false。
“三妹,看完上面的分析之后,我希望你记住一点:**当需要进行自动装箱时,如果数字在 -128 至 127 之间时,会直接使用缓存中的对象,而不是重新创建一个对象**。”
“自动装拆箱是一个很好的功能,大大节省了我们开发人员的精力,但也会引发一些麻烦,比如下面这段代码,性能就很差。”
```java
long t1 = System.currentTimeMillis();
Long sum = 0L;
for (int i = 0; i < Integer.MAX_VALUE;i++) {
sum += i;
}
long t2 = System.currentTimeMillis();
System.out.println(t2-t1);
```
“知道为什么吗?三妹。”
“难道是因为 sum 被声明成了包装类型 Long 而不是基本类型 long。”三妹若有所思。
“是滴,由于 sum 是个 Long 型,而 i 为 int 类型,`sum += i` 在执行的时候,会先把 i 强转为 long 型,然后再把 sum 拆箱为 long 型进行相加操作,之后再自动装箱为 Long 型赋值给 sum。”
“三妹,你可以试一下,把 sum 换成 long 型比较一下它们运行的时间。”
。。。。。。
“哇,sum 为 Long 型的时候,足足运行了 5825 毫秒;sum 为 long 型的时候,只需要 679 毫秒。”
“好了,三妹,今天的主题就先讲到这吧。我再去来根华子。”
-------
PS:点击「**阅读原文**」可直达《教妹学Java》专栏的 GitHub 开源地址,记得 star 哦!
\ No newline at end of file
## 浅拷贝与深拷贝
“哥,听说浅拷贝和深拷贝是 Java 面试中经常会被问到的一个问题,是这样吗?”
“还真的是,而且了解浅拷贝和深拷贝的原理,对 Java 是值传递还是引用传递也会有更深的理解。”我肯定地回答。
“不管是浅拷贝还是深拷贝,都可以通过调用 Object 类的 `clone()` 方法来完成。”我一边说,一边打开 Intellij IDEA,并找到了 `clone()` 方法的源码。
```java
@HotSpotIntrinsicCandidate
protected native Object clone() throws CloneNotSupportedException;
```
其中 `@HotSpotIntrinsicCandidate` 是 Java 9 引入的一个注解,被它标注的方法,在 HotSpot 虚拟机中会有一套高效的实现。需要注意的是,`clone()` 方法同时是一个本地(`native`)方法,它的具体实现会交给 HotSpot 虚拟机,那就意味着虚拟机在运行该方法的时候,会将其替换为更高效的 C/C++ 代码,进而调用操作系统去完成对象的克隆工作。
“哥,那你就先说浅拷贝吧!”
“好的呀。直接上实战代码。”
```java
class Writer implements Cloneable{
private int age;
private String name;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) + "{" +
"age=" + age +
", name='" + name + '\'' +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
```
Writer 类有两个字段,分别是 int 类型的 age,和 String 类型的 name。然后重写了 `toString()` 方法,方便打印对象的具体信息。并且重写了 `clone()` 方法,方法体里面也很简单,直接调用 Object 类的 `clone()` 方法。
“既然 Writer 类的 `clone()` 方法体里只有一行代码,调用的还是超类 Object 的 `clone()` 方法?为什么还要重写呢?不是多此一举吗?”三妹着急地问。
“嗯,是这样的,三妹。Object 类中的 `clone()` 方法是 protected 的,如果 Writer 类不去重写的话,Writer 类的对象是无法调用 `clone()` 方法的,因为 protected 修饰的方法对子类并不可见。”
“哦哦,那为什么要实现 Cloneable 接口呢?”三妹开启了十万个为什么的模式。
Cloneable 接口是一个标记接口,它肚子里面是空的:
```java
public interface Cloneable {
}
```
只是,如果一个类没有实现 Cloneable 接口,即便它重写了 `clone()` 方法,依然是无法调用该方法进行对象克隆的,程序在执行 `clone()` 方法的时候会抛出 CloneNotSupportedException 异常。
```java
Exception in thread "main" java.lang.CloneNotSupportedException
```
标记接口的作用其实很简单,用来表示某个功能在执行的时候是合法的。
“哦,我悟了!”三妹看来是彻底明白了我说的内容。
“接着,来测试类。”
```java
class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Writer writer1 = new Writer(18,"二哥");
Writer writer2 = (Writer) writer1.clone();
System.out.println("浅拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
writer2.setName("三妹");
System.out.println("调整了 writer2 的 name 后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
```
- 通过 new 关键字声明了一个 Writer 对象(18 岁的二哥),将其赋值给 writer1。
- 通过调用 `clone()` 方法进行对象拷贝,并将其赋值给 writer2。
- 之后打印 writer1 和 writer2。
- 将 writer2 的 name 字段调整为“三妹”。
- 再次打印。
来看一下输出结果。
```
浅拷贝后:
writer1:Writer@68837a77{age=18, name='二哥'}
writer2:Writer@b97c004{age=18, name='二哥'}
调整了 writer2 的 name 后:
writer1:Writer@68837a77{age=18, name='二哥'}
writer2:Writer@b97c004{age=18, name='三妹'}
```
可以看得出,浅拷贝后,writer1 和 writer2 引用了不同的对象,但值是相同的,说明拷贝成功。之后,修改了 writer2 的 name 字段,直接上图就明白了。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/deep-copy-01.png)
之前的例子中,Writer 类只有两个字段,没有引用类型字段。那么,我们再来看另外一个例子,为 Writer 类增加一个自定义的引用类型字段 Book,先来看 Book 的定义。
```java
class Book {
private String bookName;
private int price;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" bookName='" + bookName + '\'' +
", price=" + price +
'}';
}
}
```
有两个字段,分别是 String 类型的 bookName 和 int 类型的 price。
然后来看 Writer 类的定义。
```java
class Writer implements Cloneable{
private int age;
private String name;
private Book book;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" age=" + age +
", name='" + name + '\'' +
", book=" + book +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
```
比之前的例子多了一个自定义类型的字段 book,`clone()` 方法并没有任何改变。
再来看测试类。
```java
class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Writer writer1 = new Writer(18,"二哥");
Book book1 = new Book("编译原理",100);
writer1.setBook(book1);
Writer writer2 = (Writer) writer1.clone();
System.out.println("浅拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
Book book2 = writer2.getBook();
book2.setBookName("永恒的图灵");
book2.setPrice(70);
System.out.println("writer2.book 变更后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
```
- 通过 new 关键字声明了一个 Writer 对象(18 岁的二哥),将其赋值给 writer1。
- 通过 new 关键字声明了一个 Book 对象(100 块的编译原理),将其赋值给 book1。
- 将 writer1 的 book 字段设置为 book1。
- 通过调用 `clone()` 方法进行对象拷贝,并将其赋值给 writer2。
- 之后打印 writer1 和 writer2。
- 获取 writer2 的 book 字段,并将其赋值给 book2。
- 将 book2 的 bookName 字段调整为“永恒的图灵”,price 字段调整为 70。
- 再次打印。
来看一下输出结果。
```
浅拷贝后:
writer1:Writer@68837a77 age=18, name='二哥', book=Book@32e6e9c3 bookName='编译原理', price=100}}
writer2:Writer@6d00a15d age=18, name='二哥', book=Book@32e6e9c3 bookName='编译原理', price=100}}
writer2.book 变更后:
writer1:Writer@68837a77 age=18, name='二哥', book=Book@32e6e9c3 bookName='永恒的图灵', price=70}}
writer2:Writer@36d4b5c age=18, name='二哥', book=Book@32e6e9c3 bookName='永恒的图灵', price=70}}
```
与之前例子不同的是,writer2.book 变更后,writer1.book 也发生了改变。这是因为字符串 String 是不可变对象,一个新的值必须在字符串常量池中开辟一段新的内存空间,而自定义对象的内存地址并没有发生改变,只是对应的字段值发生了改变,见下图。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/deep-copy-02.png)
“哇,哥,果真一图胜千言,我明白了。”三妹似乎对我画的图很感兴趣呢,“那你继续说深拷贝吧!”
“嗯,三妹,你有没有注意到,浅拷贝克隆的对象中,引用类型的字段指向的是同一个,当改变任何一个对象,另外一个对象也会随之改变,除去字符串的特殊性外。”
“深拷贝和浅拷贝不同的,深拷贝中的引用类型字段也会克隆一份,当改变任何一个对象,另外一个对象不会随之改变。”
“明白了这一点后,我们再来看例子。”
```java
class Book implements Cloneable{
private String bookName;
private int price;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" bookName='" + bookName + '\'' +
", price=" + price +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
```
注意,此时的 Book 类和浅拷贝时不同,重写了 `clone()` 方法,并实现了 Cloneable 接口。为的就是深拷贝的时候也能够克隆该字段。
```java
class Writer implements Cloneable{
private int age;
private String name;
private Book book;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" age=" + age +
", name='" + name + '\'' +
", book=" + book +
'}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
Writer writer = (Writer) super.clone();
writer.setBook((Book) writer.getBook().clone());
return writer;
}
}
```
注意,此时 Writer 类也与之前的不同,`clone()` 方法当中,不再只调用 Object 的 `clone()` 方法对 Writer 进行克隆了,还对 Book 也进行了克隆。
来看测试类。
```java
class TestClone {
public static void main(String[] args) throws CloneNotSupportedException {
Writer writer1 = new Writer(18,"二哥");
Book book1 = new Book("编译原理",100);
writer1.setBook(book1);
Writer writer2 = (Writer) writer1.clone();
System.out.println("深拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
Book book2 = writer2.getBook();
book2.setBookName("永恒的图灵");
book2.setPrice(70);
System.out.println("writer2.book 变更后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
```
这个测试类和之前的浅拷贝的测试类就完全一样了,但运行结果是不同的。
```
深拷贝后:
writer1:Writer@6be46e8f age=18, name='二哥', book=Book@5056dfcb bookName='编译原理', price=100}}
writer2:Writer@6d00a15d age=18, name='二哥', book=Book@51efea79 bookName='编译原理', price=100}}
writer2.book 变更后:
writer1:Writer@6be46e8f age=18, name='二哥', book=Book@5056dfcb bookName='编译原理', price=100}}
writer2:Writer@6d00a15d age=18, name='二哥', book=Book@51efea79 bookName='永恒的图灵', price=70}}
```
不只是 writer1 和 writer2 是不同的对象,它们中的 book 也是不同的对象。所以,改变了 writer2 中的 book 并不会影响到 writer1。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/deep-copy-03.png)
不过,通过 `clone()` 方法实现的深拷贝比较笨重,因为要将所有的引用类型都重写 `clone()` 方法,当嵌套的对象比较多的时候,就废了!
“那有没有好的办法呢?”三妹急切的问。
“当然有了,利用序列化。”我胸有成竹的回答,“序列化是将对象写到流中便于传输,而反序列化则是将对象从流中读取出来。”
“写入流中的对象就是对原始对象的拷贝。需要注意的是,每个要序列化的类都要实现 Serializable 接口,该接口和 Cloneable 接口类似,都是标记型接口。”
来看例子。
```java
class Book implements Serializable {
private String bookName;
private int price;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" bookName='" + bookName + '\'' +
", price=" + price +
'}';
}
}
```
Book 需要实现 Serializable 接口。
```java
class Writer implements Serializable {
private int age;
private String name;
private Book book;
// getter/setter 和构造方法都已省略
@Override
public String toString() {
return super.toString().substring(26) +
" age=" + age +
", name='" + name + '\'' +
", book=" + book +
'}';
}
//深度拷贝
public Object deepClone() throws IOException, ClassNotFoundException {
// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(this);
// 反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
return ois.readObject();
}
}
```
Writer 类也需要实现 Serializable 接口,并且在该类中,增加了一个 `deepClone()` 的方法,利用 OutputStream 进行序列化,InputStream 进行反序列化,这样就实现了深拷贝。
来看示例。
```java
class TestClone {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Writer writer1 = new Writer(18,"二哥");
Book book1 = new Book("编译原理",100);
writer1.setBook(book1);
Writer writer2 = (Writer) writer1.deepClone();
System.out.println("深拷贝后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
Book book2 = writer2.getBook();
book2.setBookName("永恒的图灵");
book2.setPrice(70);
System.out.println("writer2.book 变更后:");
System.out.println("writer1:" + writer1);
System.out.println("writer2:" + writer2);
}
}
```
与之前测试类不同的是,调用了 `deepClone()` 方法。
```
深拷贝后:
writer1:Writer@9629756 age=18, name='二哥', book=Book@735b5592 bookName='编译原理', price=100}}
writer2:Writer@544fe44c age=18, name='二哥', book=Book@31610302 bookName='编译原理', price=100}}
writer2.book 变更后:
writer1:Writer@9629756 age=18, name='二哥', book=Book@735b5592 bookName='编译原理', price=100}}
writer2:Writer@544fe44c age=18, name='二哥', book=Book@31610302 bookName='永恒的图灵', price=70}}
```
测试结果和之前用 `clone()` 方法实现的深拷贝类似。
“不过,三妹,需要注意,由于是序列化涉及到输入流和输出流的读写,在性能上要比 HotSpot 虚拟机实现的 `clone()` 方法差很多。”我语重心长地说。
“好的,二哥,你先去休息吧,让我来琢磨一会,总结一下浅拷贝和深拷贝之间的差异。”
“嗯嗯。”
PS:点击「**阅读原文**」可直达《教妹学Java》专栏的 GitHub 开源地址,记得 star 哦!
\ No newline at end of file
## 抽象类
“二哥,你这明显加快了更新的频率呀!”三妹对于我最近的肝劲由衷的佩服了起来。
“哈哈,是呀,这次不能再断更了,我要再更 175 篇,总计 200 篇,给广大的学弟学妹们一个完整的 Java 学习体系。”我对未来充满了信心。
“那就开始吧。”三妹说。
-------
定义抽象类的时候需要用到关键字 `abstract`,放在 `class` 关键字前,就像下面这样。
```java
abstract class AbstractPlayer {
}
```
关于抽象类的命名,《阿里的 Java 开发手册》上有强调,“抽象类命名要使用 Abstract 或 Base 开头”,这条规约还是值得遵守的。
抽象类是不能实例化的,尝试通过 `new` 关键字实例化的话,编译器会报错,提示“类是抽象的,不能实例化”。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/abstract-01.png)
虽然抽象类不能实例化,但可以有子类。子类通过 `extends` 关键字来继承抽象类。就像下面这样。
```java
public class BasketballPlayer extends AbstractPlayer {
}
```
如果一个类定义了一个或多个抽象方法,那么这个类必须是抽象类。
当我们尝试在一个普通类中定义抽象方法的时候,编译器会有两处错误提示。第一处在类级别上,提示“这个类必须通过 `abstract` 关键字定义”,见下图。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/abstract-02.png)
第二处在尝试定义 abstract 的方法上,提示“抽象方法所在的类不是抽象的”,见下图。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/abstract-03.png)
抽象类中既可以定义抽象方法,也可以定义普通方法,就像下面这样:
```java
public abstract class AbstractPlayer {
abstract void play();
public void sleep() {
System.out.println("运动员也要休息而不是挑战极限");
}
}
```
抽象类派生的子类必须实现父类中定义的抽象方法。比如说,抽象类 AbstractPlayer 中定义了 `play()` 方法,子类 BasketballPlayer 中就必须实现。
```java
public class BasketballPlayer extends AbstractPlayer {
@Override
void play() {
System.out.println("我是张伯伦,篮球场上得过 100 分");
}
}
```
如果没有实现的话,编译器会提示“子类必须实现抽象方法”,见下图。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/abstract-04.png)
“二哥,抽象方法我明白了,那什么时候使用抽象方法呢?能给我讲讲它的应用场景吗?”三妹及时的插话道。
“这问题问的恰到好处呀!”我扶了扶眼镜继续说。
**第一种场景**
当我们希望一些通用的功能被多个子类复用的时候,就可以使用抽象类。比如说,AbstractPlayer 抽象类中有一个普通的方法 `sleep()`,表明所有运动员都需要休息,那么这个方法就可以被子类复用。
```java
abstract class AbstractPlayer {
public void sleep() {
System.out.println("运动员也要休息而不是挑战极限");
}
}
```
子类 BasketballPlayer 继承了 AbstractPlayer 类:
```java
class BasketballPlayer extends AbstractPlayer {
}
```
也就拥有了 `sleep()` 方法。BasketballPlayer 的对象可以直接调用父类的 `sleep()` 方法:
```java
BasketballPlayer basketballPlayer = new BasketballPlayer();
basketballPlayer.sleep();
```
子类 FootballPlayer 继承了 AbstractPlayer 类:
```java
class FootballPlayer extends AbstractPlayer {
}
```
也拥有了 `sleep()` 方法,FootballPlayer 的对象也可以直接调用父类的 `sleep()` 方法:
```java
FootballPlayer footballPlayer = new FootballPlayer();
footballPlayer.sleep();
```
这样是不是就实现了代码的复用呢?
**第二种场景**
当我们需要在抽象类中定义好 API,然后在子类中扩展实现的时候就可以使用抽象类。比如说,AbstractPlayer 抽象类中定义了一个抽象方法 `play()`,表明所有运动员都可以从事某项运动,但需要对应子类去扩展实现,表明篮球运动员打篮球,足球运动员踢足球。
```java
abstract class AbstractPlayer {
abstract void play();
}
```
BasketballPlayer 继承了 AbstractPlayer 类,扩展实现了自己的 `play()` 方法。
```java
public class BasketballPlayer extends AbstractPlayer {
@Override
void play() {
System.out.println("我是张伯伦,我篮球场上得过 100 分,");
}
}
```
FootballPlayer 继承了 AbstractPlayer 类,扩展实现了自己的 `play()` 方法。
```java
public class FootballPlayer extends AbstractPlayer {
@Override
void play() {
System.out.println("我是C罗,我能接住任意高度的头球");
}
}
```
为了进一步展示抽象类的特性,我们再来看一个具体的示例。假设现在有一个文件,里面的内容非常简单,只有一个“Hello World”,现在需要有一个读取器将内容从文件中读取出来,最好能按照大写的方式,或者小写的方式来读。
这时候,最好定义一个抽象类 BaseFileReader:
```java
abstract class BaseFileReader {
protected Path filePath;
protected BaseFileReader(Path filePath) {
this.filePath = filePath;
}
public List<String> readFile() throws IOException {
return Files.lines(filePath)
.map(this::mapFileLine).collect(Collectors.toList());
}
protected abstract String mapFileLine(String line);
}
```
- filePath 为文件路径,使用 protected 修饰,表明该成员变量可以在需要时被子类访问到。
- `readFile()` 方法用来读取文件,方法体里面调用了抽象方法 `mapFileLine()`——需要子类来扩展实现大小写的不同读取方式。
在我看来,BaseFileReader 类设计的就非常合理,并且易于扩展,子类只需要专注于具体的大小写实现方式就可以了。
小写的方式:
```java
class LowercaseFileReader extends BaseFileReader {
protected LowercaseFileReader(Path filePath) {
super(filePath);
}
@Override
protected String mapFileLine(String line) {
return line.toLowerCase();
}
}
```
大写的方式:
```java
class UppercaseFileReader extends BaseFileReader {
protected UppercaseFileReader(Path filePath) {
super(filePath);
}
@Override
protected String mapFileLine(String line) {
return line.toUpperCase();
}
}
```
从文件里面一行一行读取内容的代码被子类复用了。与此同时,子类只需要专注于自己该做的工作,LowercaseFileReader 以小写的方式读取文件内容,UppercaseFileReader 以大写的方式读取文件内容。
来看一下测试类 FileReaderTest:
```java
public class FileReaderTest {
public static void main(String[] args) throws URISyntaxException, IOException {
URL location = FileReaderTest.class.getClassLoader().getResource("helloworld.txt");
Path path = Paths.get(location.toURI());
BaseFileReader lowercaseFileReader = new LowercaseFileReader(path);
BaseFileReader uppercaseFileReader = new UppercaseFileReader(path);
System.out.println(lowercaseFileReader.readFile());
System.out.println(uppercaseFileReader.readFile());
}
}
```
在项目的 resource 目录下建一个文本文件,名字叫 helloworld.txt,里面的内容就是“Hello World”。文件的具体位置如下图所示,我用的集成开发环境是 Intellij IDEA。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/abstract-05.png)
在 resource 目录下的文件可以通过 `ClassLoader.getResource()` 的方式获取到 URI 路径,然后就可以取到文本内容了。
输出结果如下所示:
```
[hello world]
[HELLO WORLD]
```
-------
“完了吗?二哥”三妹似乎还沉浸在聆听教诲的快乐中。
“是滴,这次我们系统化的学习了抽象类,可以说面面俱到了。三妹你可以把代码敲一遍,加强了一些印象,电脑交给你了。”说完,我就跑到阳台去抽烟了。
“呼。。。。。”一个大大的眼圈飘散开来,又是愉快的一天~
## 接口
“哥,我看你朋友圈说《教妹学 Java》专栏收到了第一笔赞赏呀,虽然只有一块钱,但我也替你感到开心。”三妹的脸上洋溢着自信的微笑,仿佛这钱是打给她的一样。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-01.png)
“是啊,早上起来的时候看到这条信息,还真的是挺开心的,虽然只有一块钱,但是开源的第一笔,也是我人生当中的第一笔,真的非常感谢这个读者,值得纪念的一天。”我自己也掩饰不住内心的激动。
“有了这份鼓励,我相信你更新下去的动力更足了!”三妹今天说的话真的是特别令人喜欢。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-02.png)
“是啊是啊,所以,今天要更新第 26 讲了——接口。”我接着说,“对于面向对象编程来说,抽象是一个极具魅力的特征。如果一个程序员的抽象思维很差,那他在编程中就会遇到很多困难,无法把业务变成具体的代码。在 Java 中,可以通过两种形式来达到抽象的目的,一种上一篇的主角——[抽象类](https://mp.weixin.qq.com/s/WSmGwdtlimIFVVDVKfvrWQ),另外一种就是今天的主角——接口。”
----------
“接口是什么呀?”三妹顺着我的话题及时的插话到。
接口通过 interface 关键字来定义,它可以包含一些常量和方法,来看下面这个示例。
```java
public interface Electronic {
// 常量
String LED = "LED";
// 抽象方法
int getElectricityUse();
// 静态方法
static boolean isEnergyEfficient(String electtronicType) {
return electtronicType.equals(LED);
}
// 默认方法
default void printDescription() {
System.out.println("电子");
}
}
```
来看一下这段代码反编译后的字节码。
```java
public interface Electronic
{
public abstract int getElectricityUse();
public static boolean isEnergyEfficient(String electtronicType)
{
return electtronicType.equals("LED");
}
public void printDescription()
{
System.out.println("\u7535\u5B50");
}
public static final String LED = "LED";
}
```
发现没?接口中定义的所有变量或者方法,都会自动添加上 `public` 关键字。
接下来,我来一一解释下 Electronic 接口中的核心知识点。
1)接口中定义的变量会在编译的时候自动加上 `public static final` 修饰符(注意看一下反编译后的字节码),也就是说上例中的 LED 变量其实就是一个常量。
Java 官方文档上有这样的声明:
>Every field declaration in the body of an interface is implicitly public, static, and final.
换句话说,接口可以用来作为常量类使用,还能省略掉 `public static final`,看似不错的一种选择,对吧?
不过,这种选择并不可取。因为接口的本意是对方法进行抽象,而常量接口会对子类中的变量造成命名空间上的“污染”。
2)没有使用 `private``default` 或者 `static` 关键字修饰的方法是隐式抽象的,在编译的时候会自动加上 `public abstract` 修饰符。也就是说上例中的 `getElectricityUse()` 其实是一个抽象方法,没有方法体——这是定义接口的本意。
3)从 Java 8 开始,接口中允许有静态方法,比如说上例中的 `isEnergyEfficient()` 方法。
静态方法无法由(实现了该接口的)类的对象调用,它只能通过接口名来调用,比如说 `Electronic.isEnergyEfficient("LED")`
接口中定义静态方法的目的是为了提供一种简单的机制,使我们不必创建对象就能调用方法,从而提高接口的竞争力。
4)接口中允许定义 `default` 方法也是从 Java 8 开始的,比如说上例中的 `printDescription()` 方法,它始终由一个代码块组成,为,实现该接口而不覆盖该方法的类提供默认实现。既然要提供默认实现,就要有方法体,换句话说,默认方法后面不能直接使用“;”号来结束——编译器会报错。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-03.png)
“为什么要在接口中定义默认方法呢?”三妹好奇地问到。
允许在接口中定义默认方法的理由很充分,因为一个接口可能有多个实现类,这些类就必须实现接口中定义的抽象类,否则编译器就会报错。假如我们需要在所有的实现类中追加某个具体的方法,在没有 `default` 方法的帮助下,我们就必须挨个对实现类进行修改。
由之前的例子我们就可以得出下面这些结论:
- 接口中允许定义变量
- 接口中允许定义抽象方法
- 接口中允许定义静态方法(Java 8 之后)
- 接口中允许定义默认方法(Java 8 之后)
除此之外,我们还应该知道:
1)接口不允许直接实例化,否则编译器会报错。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-04.png)
需要定义一个类去实现接口,见下例。
```java
public class Computer implements Electronic {
public static void main(String[] args) {
new Computer();
}
@Override
public int getElectricityUse() {
return 0;
}
}
```
然后再实例化。
```
Electronic e = new Computer();
```
2)接口可以是空的,既可以不定义变量,也可以不定义方法。最典型的例子就是 Serializable 接口,在 `java.io` 包下。
```java
public interface Serializable {
}
```
Serializable 接口用来为序列化的具体实现提供一个标记,也就是说,只要某个类实现了 Serializable 接口,那么它就可以用来序列化了。
3)不要在定义接口的时候使用 final 关键字,否则会报编译错误,因为接口就是为了让子类实现的,而 final 阻止了这种行为。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-05.png)
4)接口的抽象方法不能是 private、protected 或者 final,否则编译器都会报错。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-06.png)
5)接口的变量是隐式 `public static final`(常量),所以其值无法改变。
“接口可以做什么呢?”三妹见缝插针,问的很及时。
第一,使某些实现类具有我们想要的功能,比如说,实现了 Cloneable 接口的类具有拷贝的功能,实现了 Comparable 或者 Comparator 的类具有比较功能。
Cloneable 和 Serializable 一样,都属于标记型接口,它们内部都是空的。实现了 Cloneable 接口的类可以使用 `Object.clone()` 方法,否则会抛出 CloneNotSupportedException。
```java
public class CloneableTest implements Cloneable {
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
public static void main(String[] args) throws CloneNotSupportedException {
CloneableTest c1 = new CloneableTest();
CloneableTest c2 = (CloneableTest) c1.clone();
}
}
```
运行后没有报错。现在把 `implements Cloneable` 去掉。
```java
public class CloneableTest {
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
public static void main(String[] args) throws CloneNotSupportedException {
CloneableTest c1 = new CloneableTest();
CloneableTest c2 = (CloneableTest) c1.clone();
}
}
```
运行后抛出 CloneNotSupportedException:
```
Exception in thread "main" java.lang.CloneNotSupportedException: com.cmower.baeldung.interface1.CloneableTest
at java.base/java.lang.Object.clone(Native Method)
at com.cmower.baeldung.interface1.CloneableTest.clone(CloneableTest.java:6)
at com.cmower.baeldung.interface1.CloneableTest.main(CloneableTest.java:11)
```
第二,Java 原则上只支持单一继承,但通过接口可以实现多重继承的目的。
如果有两个类共同继承(extends)一个父类,那么父类的方法就会被两个子类重写。然后,如果有一个新类同时继承了这两个子类,那么在调用重写方法的时候,编译器就不能识别要调用哪个类的方法了。这也正是著名的菱形问题,见下图。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/interface-07.png)
简单解释下,ClassC 同时继承了 ClassA 和 ClassB,ClassC 的对象在调用 ClassA 和 ClassB 中重写的方法时,就不知道该调用 ClassA 的方法,还是 ClassB 的方法。
接口没有这方面的困扰。来定义两个接口,Fly 接口会飞,Run 接口会跑。
```java
public interface Fly {
void fly();
}
public interface Run {
void run();
}
```
然后让 Pig 类同时实现这两个接口。
```java
public class Pig implements Fly,Run{
@Override
public void fly() {
System.out.println("会飞的猪");
}
@Override
public void run() {
System.out.println("会跑的猪");
}
}
```
在某种形式上,接口实现了多重继承的目的:现实世界里,猪的确只会跑,但在雷军的眼里,站在风口的猪就会飞,这就需要赋予这只猪更多的能力,通过抽象类是无法实现的,只能通过接口。
第三,实现多态。
什么是多态呢?通俗的理解,就是同一个事件发生在不同的对象上会产生不同的结果,鼠标左键点击窗口上的 X 号可以关闭窗口,点击超链接却可以打开新的网页。
多态可以通过继承(`extends`)的关系实现,也可以通过接口的形式实现。
Shape 接口表示一个形状。
```java
public interface Shape {
String name();
}
```
Circle 类实现了 Shape 接口,并重写了 `name()` 方法。
```java
public class Circle implements Shape {
@Override
public String name() {
return "圆";
}
}
```
Square 类也实现了 Shape 接口,并重写了 `name()` 方法。
```java
public class Square implements Shape {
@Override
public String name() {
return "正方形";
}
}
```
然后来看测试类。
```java
List<Shape> shapes = new ArrayList<>();
Shape circleShape = new Circle();
Shape squareShape = new Square();
shapes.add(circleShape);
shapes.add(squareShape);
for (Shape shape : shapes) {
System.out.println(shape.name());
}
```
这就实现了多态,变量 circleShape、squareShape 的引用类型都是 Shape,但执行 `shape.name()` 方法的时候,Java 虚拟机知道该去调用 Circle 的 `name()` 方法还是 Square 的 `name()` 方法。
说一下多态存在的 3 个前提:
1、要有继承关系,比如说 Circle 和 Square 都实现了 Shape 接口。
2、子类要重写父类的方法,Circle 和 Square 都重写了 `name()` 方法。
3、父类引用指向子类对象,circleShape 和 squareShape 的类型都为 Shape,但前者指向的是 Circle 对象,后者指向的是 Square 对象。
然后,我们来看一下测试结果:
```
正方形
```
也就意味着,尽管在 for 循环中,shape 的类型都为 Shape,但在调用 `name()` 方法的时候,它知道 Circle 对象应该调用 Circle 类的 `name()` 方法,Square 对象应该调用 Square 类的 `name()` 方法。
“哦,我理解了哥。那我再问一下,抽象类和接口有什么差别呢?”
“哇,三妹呀,你这个问题恰到好处,问到了点子上。”我不由得为三妹竖起了大拇指。
1)语法层面上
- 接口中不能有 public 和 protected 修饰的方法,抽象类中可以有。
- 接口中的变量只能是隐式的常量,抽象类中可以有任意类型的变量。
- 一个类只能继承一个抽象类,但却可以实现多个接口。
2)设计层面上
抽象类是对类的一种抽象,继承抽象类的子类和抽象类本身是一种 `is-a` 的关系。
接口是对类的某种行为的一种抽象,接口和类之间并没有很强的关联关系,举个例子来说,所有的类都可以实现 `Serializable` 接口,从而具有序列化的功能,但不能说所有的类和 Serializable 之间是 `is-a` 的关系。
--------
“好了,三妹,接口就学到这吧,下课,哈哈哈。”我抬起头看了看窗外,天气还真不错,希望五一的张家界也能晴空万里~
“嗯嗯,哥,休息下吧,我给你揉揉肩膀~~~~”不得不说,有个贴心的妹妹还真的是挺舒服。。。。。
\ No newline at end of file
## 值传递与引用传递
“哥,说说 Java 到底是值传递还是引用传递吧?”三妹一脸的困惑,看得出来她被这个问题折磨得不轻。
“说实在的,我在一开始学 Java 的时候也被这个问题折磨得够呛,总以为基本数据类型在传参的时候是值传递,而引用类型是引用传递。”我对三妹袒露了心声,为的就是让她不再那么焦虑,她哥当年也是这么过来的。
C 语言是很多编程语言的母胎,包括 Java,那么对于 C 语言来说,所有的方法参数都是“通过值”传递的,也就是说,传递给被调用方法的参数值存放在临时变量中,而不是存放在原来的变量中。这就意味着,被调用的方法不能修改调用方法中变量的值,而只能修改其私有变量的临时副本的值。
Java 继承了 C 语言这一特性,因此 Java 是按照值来传递的。
接下来,我们必须得搞清楚,到底什么是值传递(pass by value),什么是引用传递(pass by reference),否则,讨论 Java 到底是值传递还是引用传递就显得毫无意义。
当一个参数按照值的方式在两个方法之间传递时,调用者和被调用者其实是用的两个不同的变量——被调用者中的变量(原始值)是调用者中变量的一份拷贝,对它们当中的任何一个变量修改都不会影响到另外一个变量,据说 Fortran 语言是通过引用传递的。
“Fortran 语言?”三妹睁大了双眼,似乎听见了什么新的名词。
“是的,Fortran 语言,1957 年由 IBM 公司开发,是世界上第一个被正式采用并流传至今的高级编程语言。”
当一个参数按照引用传递的方式在两个方法之间传递时,调用者和被调用者其实用的是同一个变量,当该变量被修改时,双方都是可见的。
“我们之所以容易搞不清楚 Java 到底是值传递还是引用传递,主要是因为 Java 中的两类数据类型的叫法容易引发误会,比如说 int 是基本类型,说它是值传递的,我们就很容易理解;但对于引用类型,比如说 String,说它也是值传递的时候,我们就容易弄不明白。”
我们来看看基本数据类型和引用数据类型之间的差别。
```java
int age = 18;
String name = "二哥";
```
age 是基本类型,值就保存在变量中,而 name 是引用类型,变量中保存的是对象的地址。一般称这种变量为对象的引用,引用存放在栈中,而对象存放在堆中。
这里说的栈和堆,是指内存中的一块区域,和数据结构中的栈和堆不一样。栈是由编译器自动分配释放的,所以适合存放编译期就确定生命周期的数据;而堆中存放的数据,编译器是不需要知道生命周期的,创建后的回收工作由垃圾收集器来完成。
“画幅图。”
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/pass-by-value-01.png)
当用 = 赋值运算符改变 age 和 name 的值时。
```java
age = 16;
name = "三妹";
```
对于基本类型 age,赋值运算符会直接改变变量的值,原来的值被覆盖。
对于引用类型 name,赋值运算符会改变对象引用中保存的地址,原来的地址被覆盖,但原来的对象不会被覆盖。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/pass-by-value-02.png)
“三妹,注意听,接下来,我们来说说基本数据类型的参数传递。”
Java 有 8 种基本数据类型,分别是 int、long、byte、short、float、double 、char 和 boolean,就拿 int 类型来举例吧。
```java
class PrimitiveTypeDemo {
public static void main(String[] args) {
int age = 18;
modify(age);
System.out.println(age);
}
private static void modify(int age1) {
age1 = 30;
}
}
```
1)`main()` 方法中的 age 为基本类型,所以它的值 18 直接存储在变量中。
2)调用 `modify()` 方法的时候,将会把 age 的值 18 复制给形参 age1。
3)`modify()` 方法中,对 age1 做出了修改。
4)回到 `main()` 方法中,age 的值仍然为 18,并没有发生改变。
如果我们想让 age 的值发生改变,就需要这样做。
```java
class PrimitiveTypeDemo1 {
public static void main(String[] args) {
int age = 18;
age = modify(age);
System.out.println(age);
}
private static int modify(int age1) {
age1 = 30;
return age1;
}
}
```
第一,让 `modify()` 方法有返回值;
第二,使用赋值运算符重新对 age 进行赋值。
“好了,再来说说引用类型的参数传递。”
就以 String 为例吧。
```java
class ReferenceTypeDemo {
public static void main(String[] args) {
String name = "二哥";
modify(name);
System.out.println(name);
}
private static void modify(String name1) {
name1 = "三妹";
}
}
```
在调用 `modify()` 方法的时候,形参 name1 复制了 name 的地址,指向的是堆中“二哥”的位置。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/pass-by-value-03.png)
`modify()` 方法调用结束后,改变了形参 name1 的地址,但 `main()` 方法中 name 并没有发生改变。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/object-class/pass-by-value-04.png)
总结:
- Java 中的参数传递是按值传递的。
- 如果参数是基本类型,传递的是基本类型的字面量值的拷贝。
- 如果参数是引用类型,传递的是引用的对象在堆中地址的拷贝。
“好了,三妹,今天的学习就到这吧。”
## 字符串常量池
“三妹,今天我们来学习一下字符串常量池吧,这是字符串非常中关键的一个知识点。”我话音未落,青岛路小学那边传来了嘹亮的歌声就钻进了我的耳朵,“唱 ~ 山 ~ 歌 ~”
三妹说,“好呀,开始吧,哥。”
“先从这道面试题开始吧!”
```java
String s = new String("二哥");
```
“这行代码创建了几个对象?”
“不就一个吗?”三妹不假思索地回答。
“不,两个!”我直接否定了三妹的答案,“使用 new 关键字创建一个字符串对象时,Java 虚拟机会先在字符串常量池中查找有没有‘二哥’这个字符串对象,如果有,就不会在字符串常量池中创建‘二哥’这个对象了,直接在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的对象地址返回赋值给变量 s。”
“如果没有,先在字符串常量池中创建一个‘二哥’的字符串对象,然后再在堆中创建一个‘二哥’的字符串对象,然后将堆中这个‘二哥’的字符串对象地址返回赋值给变量 s。”
“为什么要先在字符串常量池中创建对象,然后再在堆上创建呢?这样不就多此一举了?”三妹敏锐地发现了问题。
我回答,“由于字符串的使用频率实在是太高了,所以 Java 虚拟机为了提高性能和减少内存开销,在创建字符串对象的时候进行了一些优化,特意为字符串开辟了一个字符串常量池。”
通常情况下,我们会采用双引号的方式来创建字符串对象,而不是通过 new 关键字的方式:
```java
String s = "三妹";
```
当执行 `String s = "三妹"` 时,Java 虚拟机会先在字符串常量池中查找有没有“三妹”这个字符串对象,如果有,则不创建任何对象,直接将字符串常量池中这个“三妹”的对象地址返回,赋给变量 s;如果没有,在字符串常量池中创建“三妹”这个对象,然后将其地址返回,赋给变量 s。
“哦,我明白了,哥。”三妹突然插话到,“有了字符串常量池,就可以通过双引号的方式直接创建字符串对象,不用再通过 new 的方式在堆中创建对象了,对吧?”
“是滴。new 的方式始终会创建一个对象,不管字符串的内容是否已经存在,而双引号的方式会重复利用字符串常量池中已经存在的对象。”我说。
来看下面这个例子:
```java
String s = new String("二哥");
String s1 = new String("二哥");
```
按照我们之前的分析,这两行代码会创建三个对象,字符串常量池中一个,堆上两个。
再来看下面这个例子:
```java
String s = "三妹";
String s1 = "三妹";
```
这两行代码只会创建一个对象,就是字符串常量池中的那个。这样的话,性能肯定就提高了!
“那哥,字符串常量池在内存中的什么位置呢?”三妹问。
我说,“三妹,你这个问题问得好呀!”
在 Java 8 之前,字符串常量池在永久代中。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/string/constant-pool-01.png)
Java 8 之后,移除了永久代,字符串常量池就移到了堆中。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/string/constant-pool-02.png)
“哥,能再简单给我解释一下方法区,永久代和元空间的概念吗?有点模糊。”三妹说。
我说,“可以呀。”
- 方法区是 Java 虚拟机规范中的一个概念,就像是一个接口吧;
- 永久代是 HotSpot 虚拟机中对方法的一个实现,就像是接口的实现类;
- Java 8 的时候,移除了永久代,取而代之的是元空间,是方法区的另外一个实现。
永久代是放在运行时数据区中的,所以它的大小受到 Java 虚拟机本身大小的限制,所以 Java 8 之前,会经常遇到 `java.lang.OutOfMemoryError: PremGen Space` 的异常,PremGen Space 就是方法区的意思;而元空间是直接放在内存中的,所以只受本机可用内存的限制,虽然也会发生内存溢出,但出现的几率相对之前就小了很多。
“明白了吧,三妹?”我问。
“嗯嗯。”三妹回答。
“那关于字符串常量池,就先说这么多吧,是不是还挺有意思的。”我说。
“是的,我现在是彻底搞懂了字符串常量池,哥,你真棒!”三妹说。
PS:点击「**阅读原文**」可直达《教妹学Java》专栏的 GitHub 开源地址,记得 star 哦!
\ No newline at end of file
## intern
“哥,你发给我的那篇文章我看了,结果直接把我给看得不想学 Java 了!”三妹气冲冲地说。
“哪一篇啊?”看着三妹面色沉重,我关心地问到。
“就是美团技术团队深入解析 `String.intern()` 那篇啊!”三妹回答。
>https://tech.meituan.com/2014/03/06/in-depth-understanding-string-intern.html
“哦,我想起来了,不挺好一篇文章嘛,深入浅出,精品中的精品,看完后你应该对 String 的 intern 彻底理解了才对呀。”
“好是好,但我就是看不懂!”三妹委屈地说,“哥,还是你亲自给我讲讲吧?”
“好吧,上次学的[字符串常量池](https://mp.weixin.qq.com/s/b69zXknKLIa3FWs0Yj23xA)你都搞清楚了吧?”
“嗯。”三妹微微的点了点头。
要理解美团技术团队的这篇文章,你只需要记住这几点内容:
第一,使用双引号声明的字符串对象会保存在字符串常量池中。
第二,使用 new 关键字创建的字符串对象会先从字符串常量池中找,如果没找到就创建一个,然后再在堆中创建字符串对象;如果找到了,就直接在堆中创建字符串对象。
第三,针对没有使用双引号声明的字符串对象来说,就像下面代码中的 s1 那样:
```java
String s1 = new String("二哥") + new String("三妹");
```
如果想把 s1 的内容也放入字符串常量池的话,可以调用 `intern()` 方法来完成。
不过,需要注意的是,Java 7 的时候,字符串常量池从永久代中移动到了堆中,虽然此时永久代还没有完全被移除。Java 8 的时候,永久代被彻底移除。
这个变化也直接影响了 `String.intern()` 方法在执行时的策略,Java 7 之前,执行 `String.intern()` 方法的时候,不管对象在堆中是否已经创建,字符串常量池中仍然会创建一个内容完全相同的新对象; Java 7 之后呢,由于字符串常量池放在了堆中,执行 `String.intern()` 方法的时候,如果对象在堆中已经创建了,字符串常量池中就不需要再创建新的对象了,而是直接保存堆中对象的引用,也就节省了一部分的内存空间。
“三妹,来猜猜这段代码输出的结果吧。”我说。
```java
String s1 = new String("二哥三妹");
String s2 = s1.intern();
System.out.println(s1 == s2);
```
“哥,这我完全猜不出啊,还是你直接解释吧。”三妹说。
“好吧。”
第一行代码,字符串常量池中会先创建一个“二哥三妹”的对象,然后堆中会再创建一个“二哥三妹”的对象,s1 引用的是堆中的对象。
第二行代码,对 s1 执行 `intern()` 方法,该方法会从字符串常量池中查找“二哥三妹”这个字符串是否存在,此时是存在的,所以 s2 引用的是字符串常量池中的对象。
也就意味着 s1 和 s2 的引用地址是不同的,一个来自堆,一个来自字符串常量池,所以输出的结果为 false。
“来看一下运行结果。”我说。
```
false
```
“我来画幅图,帮助你理解下。”看到三妹惊讶的表情,我耐心地说。
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/string/intern-01.png)
“这下理解了吧?”我问三妹。
“嗯嗯,一下子就豁然开朗了!”三妹说。
“好,我们再来看下面这段代码。”
```java
String s1 = new String("二哥") + new String("三妹");
String s2 = s1.intern();
System.out.println(s1 == s2);
```
“难道也输出 false ?”三妹有点不确定。
“不,这段代码会输出 true。”我否定了三妹的猜测。
“为啥呀?”三妹迫切地想要知道答案。
第一行代码,会在字符串常量池中创建两个对象,一个是“二哥”,一个是“三妹”,然后在堆中会创建两个匿名对象“二哥”和“三妹”(可以暂时忽略),最后还有一个“二哥三妹”的对象,s1 引用的是堆中“二哥三妹”这个对象。
第二行代码,对 s1 执行 `intern()` 方法,该方法会从字符串常量池中查找“二哥三妹”这个对象是否存在,此时不存在的,但堆中已经存在了,所以字符串常量池中保存的是堆中这个“二哥三妹”对象的引用,也就是说,s2 和 s1 的引用地址是相同的,所以输出的结果为 true。
“来看一下运行结果。”我胸有成竹地说。
```
true
```
“我再来画幅图,帮助你理解下。”
![](https://cdn.jsdelivr.net/gh/itwanger/Tech-Sister-Learn-Java/images/string/intern-02.png)
“哇,我明白了!”三妹长舒一口气,大有感慨 intern 也没什么难理解的意味。
不过需要注意的是,尽管 intern 可以确保所有具有相同内容的字符串共享相同的内存空间,但也不要烂用 intern,因为任何的缓存池都是有大小限制的,不能无缘无故就占用了相对稀缺的缓存空间,导致其他字符串没有坑位可占。
另外,字符串常量池本质上是一个固定大小的 StringTable,如果放进去的字符串过多,就会造成严重的哈希冲突,从而导致链表变长,链表变长也就意味着字符串常量池的性能会大幅下降,因为要一个一个找是需要花费时间的。
“好了,三妹,关于 String 的 intern 就讲到这吧,这次理解了吧?”我问。
“哥,你真棒!”
看到三妹一点一滴的进步,我也感到由衷的开心。
PS:点击「**阅读原文**」可直达《教妹学Java》专栏的 GitHub 开源地址,记得 star 哦!
\ No newline at end of file
## 字符串源码分析
我正坐在沙发上津津有味地读刘欣大佬的《码农翻身》——Java 帝国这一章,门铃响了。起身打开门一看,是三妹,她从学校回来了。
“三妹,你回来的真及时,今天我们打算讲 Java 中的字符串呢。”等三妹换鞋的时候我说。
“哦,可以呀,哥。听说字符串的细节特别多,什么字符串常量池了、字符串不可变性了、字符串拼接了、字符串长度限制了等等,你最好慢慢讲,否则我可能一时半会消化不了。”三妹的态度显得很诚恳。
“嗯,我已经想好了,今天就只带你大概认识一下字符串,具体的细节咱们后面再慢慢讲,保证你能及时消化。”
“好,那就开始吧。”三妹已经准备好坐在了电脑桌的边上。
我应了一声后走到电脑桌前坐下来,顺手打开 Intellij IDEA,并找到了 String 的源码。
```java
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
private int hash;
}
```
“第一,String 类是 final 的,意味着它不能被子类继承。”
“第二,String 类实现了 Serializable 接口,意味着它可以序列化。”
“第三,String 类实现了 Comparable 接口,意味着最好不要用‘==’来比较两个字符串是否相等,而应该用 `compareTo()` 方法去比较。”
“第四,StringBuffer、StringBuilder 和 String 一样,都实现了 CharSequence 接口,所以它们仨属于近亲。由于 String 是不可变的,所以遇到字符串拼接的时候就可以考虑一下 String 的另外两个好兄弟,StringBuffer 和 StringBuilder,它俩是可变的。”
“第五,Java 9 以前,String 是用 char 型数组实现的,之后改成了 byte 型数组实现,并增加了 coder 来表示编码。在 Latin1 字符为主的程序里,可以把 String 占用的内存减少一半。当然,天下没有免费的午餐,这个改进在节省内存的同时引入了编码检测的开销。”
“第六,每一个字符串都会有一个 hash 值,这个哈希值在很大概率是不会重复的,因此 String 很适合来作为 HashMap 的键值。”
“String 可能是 Java 中使用频率最高的引用类型了,因此 String 类的设计者可以说是用心良苦。”
比如说 String 的不可变性。
- String 类被 final 关键字修饰,所以它不会有子类,这就意味着没有子类可以重写它的方法,改变它的行为。
- String 类的数据存储在 `byte[]` 数组中,而这个数组也被 final 关键字修饰了,这就表示 String 对象是没法被修改的,只要初始化一次,值就确定了。
“哥,为什么要这样设计呢?”三妹有些不解。
“我先简单来说下,三妹,能懂最好,不能懂后面再细说。”
第一,可以保证 String 对象的安全性,避免被篡改,毕竟像密码这种隐私信息一般就是用字符串存储的。
第二,保证哈希值不会频繁变更。毕竟要经常作为哈希表的键值,经常变更的话,哈希表的性能就会很差劲。
第三,可以实现字符串常量池。
“由于字符串的不可变性,String 类的一些方法实现最终都返回了新的字符串对象。”等三妹稍微缓了一会后,我继续说到。
“就拿 `substring()` 方法来说。”
```java
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = length() - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
if (beginIndex == 0) {
return this;
}
return isLatin1() ? StringLatin1.newString(value, beginIndex, subLen)
: StringUTF16.newString(value, beginIndex, subLen);
}
// StringLatin1.newString
public static String newString(byte[] val, int index, int len) {
return new String(Arrays.copyOfRange(val, index, index + len),
LATIN1);
}
// UTF16.newString
public static String newString(byte[] val, int index, int len) {
if (String.COMPACT_STRINGS) {
byte[] buf = compress(val, index, len);
if (buf != null) {
return new String(buf, LATIN1);
}
}
int last = index + len;
return new String(Arrays.copyOfRange(val, index << 1, last << 1), UTF16);
}
```
`substring()` 方法用于截取字符串,不管是 Latin1 字符还是 UTF16 字符,最终返回的都是 new 出来的新字符串对象。
“还有 `concat()` 方法。”
```java
public String concat(String str) {
int olen = str.length();
if (olen == 0) {
return this;
}
if (coder() == str.coder()) {
byte[] val = this.value;
byte[] oval = str.value;
int len = val.length + oval.length;
byte[] buf = Arrays.copyOf(val, len);
System.arraycopy(oval, 0, buf, val.length, oval.length);
return new String(buf, coder);
}
int len = length();
byte[] buf = StringUTF16.newBytesFor(len + olen);
getBytes(buf, 0, UTF16);
str.getBytes(buf, len, UTF16);
return new String(buf, UTF16);
}
```
`concat()` 方法用于拼接字符串,不管编码是否一致,最终也返回的是新的字符串对象。
`replace()` 替换方法其实也一样,三妹,你可以自己一会看一下源码,也是返回新的字符串对象。”
“这就意味着,不管是截取、拼接,还是替换,都不是在原有的字符串上进行的,而是重新生成了新的字符串对象。也就是说,这些操作执行过后,**原来的字符串对象并没有发生改变**。”
“三妹,你记住,String 对象一旦被创建后就固定不变了,对 String 对象的任何修改都不会影响到原来的字符串对象,都会生成新的字符串对象。”
“嗯嗯,记住了,哥。”三妹很乖。
“那今天就先讲到这吧,后面我们再对每一个细分领域深入地展开一下。你可以找一些资料先预习下,我出去散会心。。。。。”
PS:点击「**阅读原文**」可直达《教妹学Java》专栏的 GitHub 开源地址,记得 star 哦!
\ No newline at end of file
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册