JAVA面试汇总第二章 类和数据结构

克隆和序列化应用 + 面试题

克隆

在开始学习克隆之前,我们先来看看下面的代码,普通的对象复制,存在什么问题?


class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
// 等号赋值( 基本类型)
int number = 6;
int number2 = number;
// 修改 number2 的值
number2 = 9;
System.out.println(“number:” + number);
System.out.println(“number2:” + number2);
// 等号赋值(对象)
Dog dog = new Dog();
dog.name = “旺财”;
dog.age = 5;
Dog dog2 = dog;
// 修改 dog2 的值
dog2.name = “大黄”;
dog2.age = 3;
System.out.println(dog.name + “,” + dog.age + “岁”);
System.out.println(dog2.name + “,” + dog2.age + “岁”);
}
}

程序执行结果:


number:6
number2:9
大黄,3岁
大黄,3岁

可以看出,如果使用等号复制时,对于值类型来说,彼此之间的修改操作是相对独立的,而对于引用类型来说,因为复制的是引用对象的内存地址,所以修改其中一个值,另一个值也会跟着变化,原理如下图所示:

JAVA面试汇总第二章 类和数据结构_第1张图片

因此为了防止这种问题的发生,就要使用对象克隆来解决引用类型复制的问题。

1)浅克隆

默认的 clone() 方法,为浅克隆,代码如下:


class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
Dog dog = new Dog();
dog.name = “旺财”;
dog.age = 5;
// 克隆
Dog dog3 = (Dog) dog.clone();
dog3.name = “小白”;
dog3.age = 2;
System.out.println(dog.name + “,” + dog.age + “岁”);
System.out.println(dog3.name + “,” + dog3.age + “岁”);
}
}
class Dog implements Cloneable {
public String name;
public int age;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

程序执行结果:


旺财,5岁
小白,2岁

可以看出使用克隆就可以解决引用类型复制的问题了,原理如下图所示:

JAVA面试汇总第二章 类和数据结构_第2张图片

以上这种复制方式叫做 浅克隆。

浅克隆的实现条件 :需要克隆的对象必须实现 Cloneable 接口,并重写 clone() 方法,即可实现对此对象的克隆。

然而 使用浅克隆也会存在一个问题 ,请参考以下代码。


class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
DogChild dogChild = new DogChild();
dogChild.name = “二狗”;
Dog dog4 = new Dog();
dog4.name = “大黄”;
dog4.dogChild = dogChild;
Dog dog5 = (Dog) dog4.clone();
dog5.name = “旺财”;
dog5.dogChild.name = “狗二”;
System.out.println(“dog name 4:”+dog4.name);
System.out.println(“dog name 5:”+dog5.name);
System.out.println(“dog child name 4:”+dog4.dogChild.name);
System.out.println(“dog child name 5:”+dog5.dogChild.name);
}
}
class Dog implements Cloneable {
public String name;
public DogChild dogChild;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
class DogChild {
public String name;
}

程序执行结果:


dog name 4:大黄
dog name 5:旺财
dog child name 4:狗二
dog child name 5:狗二

也就是说浅克隆,只会复制对象的值类型,而不会复制对象的引用类型。原因如下图所示:

JAVA面试汇总第二章 类和数据结构_第3张图片

要处理引用类型不被复制的问题,就要使用到 深克隆

2)深克隆

定义 :深克隆就是复制整个对象信息,包含值类型和引用类型。

深克隆的实现方式 通常包含以下两种。

  • 序列化实现深克隆:先将原对象序列化到内存的字节流中,再从字节流中反序列化出刚刚存储的对象,这个新对象和原对象就不存在任何地址上的共享,这样就实现了深克隆。
  • 所有引用类型都实现克隆:要复制对象的所有引用类型都要实现克隆,所有对象都是复制的新对象,从而实现了深克隆。

深克隆实现方式一:序列化

实现思路:先将要拷贝对象写入到内存中的字节流中,然后再从这个字节流中读出刚刚存储的信息,作为一个新对象返回,那么这个新对象和原对象就不存在任何地址上的共享,自然实现了深拷贝。请参考以下代码:


class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
BirdChild birdChild = new BirdChild();
birdChild.name = “小小鸟”;
Bird bird = new Bird();
bird.name = “小鸟”;
bird.birdChild = birdChild;
// 使用序列化克隆对象
Bird bird2 = CloneUtils.clone(bird);
bird2.name = “黄雀”;
bird2.birdChild.name = “小黄雀”;
System.out.println(“bird name:” + bird.name);
System.out.println(“bird child name:” + bird.birdChild.name);
System.out.println(“bird name 2:” + bird2.name);
System.out.println(“bird child name 2:” + bird2.birdChild.name);
}
}
class CloneUtils {
public static T clone(T obj) {
T cloneObj = null;
try {
//写入字节流
ByteArrayOutputStream bo = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bo);
oos.writeObject(obj);
oos.close();
//分配内存,写入原始对象,生成新对象
ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());//获取上面的输出字节流
ObjectInputStream oi = new ObjectInputStream(bi);
//返回生成的新对象
cloneObj = (T) oi.readObject();
oi.close();
} catch (Exception e) {
e.printStackTrace();
}
return cloneObj;
}
}

程序执行结果:


bird name:小鸟
bird child name:小小鸟
bird name 2:黄雀
bird child name 2:小黄雀

深克隆实现方式二:所有引用类型都实现克隆


class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
ParrotChild parrotChild = new ParrotChild();
parrotChild.name = “小鹦鹉”;
Parrot parrot = new Parrot();
parrot.name = “大鹦鹉”;
parrot.parrotChild = parrotChild;
// 克隆
Parrot parrot2 = (Parrot) parrot.clone();
parrot2.name = “老鹦鹉”;
parrot2.parrotChild.name = “少鹦鹉”;
System.out.println(“parrot name:” + parrot.name);
System.out.println(“parrot child name:” + parrot.parrotChild.name);
System.out.println(“parrot name 2:” + parrot2.name);
System.out.println(“parrot child name 2:” + parrot2.parrotChild.name);
}
}
class Parrot implements Cloneable {
public String name;
public ParrotChild parrotChild;
@Override
protected Object clone() throws CloneNotSupportedException {
Parrot bird = (Parrot) super.clone();
bird.parrotChild = (ParrotChild) parrotChild.clone();
return bird;
}
}
class ParrotChild implements Cloneable {
public String name;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

程序执行结果:


parrot name:大鹦鹉
parrot child name:小鹦鹉
parrot name 2:老鹦鹉
parrot child name 2:少鹦鹉

序列化和反序列化

1)介绍

内存中的数据对象只有转换成二进制流才能进行数据持久化或者网络传输,将对象转换成二进制流的过程叫做序列化(Serialization);相反,把二进制流恢复为数据对象的过程就称之为反序列化(Deserialization)。

2)序列化和反序列代码实现

先把对象序列化到磁盘,再从磁盘中反序列化出对象,请参考以下代码:


class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 对象赋值
User user = new User();
user.setName(“老王”);
user.setAge(30);
System.out.println(user);
// 创建输出流(序列化内容到磁盘)
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(“test.out”));
// 序列化对象
oos.writeObject(user);
oos.flush();
oos.close();
// 创建输入流(从磁盘反序列化)
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(“test.out”));
// 反序列化
User user2 = (User) ois.readObject();
ois.close();
System.out.println(user2);
}
}
class User implements Serializable {
private static final long serialVersionUID = 3831264392873197003L;
private String name;
private int age;
@Override
public String toString() {
return “{name:” + name + “,age:” + age + “}”;
}
// setter/getter…
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}

程序执行结果:


{name:老王,age:30}
{name:老王,age:30}

更多序列化和反序列化的实现方式以及代码示例,请看下文面试部分的内容。

开发工具设置 :IDEA 开启自动生成 serialVersionUID
点击 Settings → Inspections → 搜索 Serialization issues → 勾选 Serializable class
without ‘SerialVersionUID’ 保存设置,如下图所示:

JAVA面试汇总第二章 类和数据结构_第4张图片

设置完之后,光标放到类名上,点击提示,生成 serialVersionUID,如下图所示:

JAVA面试汇总第二章 类和数据结构_第5张图片

相关面试题

1.serialVersionUID 的作用是什么?

答:如果显示定义了 serialVersionUID 值之后,可以使序列化和反序列化向后兼容。也就是说如果 serialVersionUID
的值相同,修改对象的字段(删除或增加),程序不会报错,之后给没有的字段赋值为 null,而如果没有指定 serialVersionUID
的值,如果修改对象的字段,程序就会报错。如下图所示:

2.可序列化接口(Serializalbe)的用途是什么?

答:可序列化 Serializalbe 接口存在于 java.io 包中,构成了 Java
序列化机制的核心,它没有任何方法,它的用途是标记某对象为可序列化对象,指示编译器使用 Java 序列化机制序列化此对象。

3.常用的序列化方式都有哪些?

答:常用的序列化有以下三种方式:

1)Java 原生序列化方式

请参考以下代码:


// 序列化和反序列化
class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 对象赋值
User user = new User();
user.setName(“老王”);
user.setAge(30);
System.out.println(user);
// 创建输出流(序列化内容到磁盘)
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(“test.out”));
// 序列化对象
oos.writeObject(user);
oos.flush();
oos.close();
// 创建输入流(从磁盘反序列化)
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(“test.out”));
// 反序列化
User user2 = (User) ois.readObject();
ois.close();
System.out.println(user2);
}
}
class User implements Serializable {
private static final long serialVersionUID = 5132320539584511249L;
private String name;
private int age;
@Override
public String toString() {
return “{name:” + name + “,age:” + age + “}”;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}

2)JSON 格式,可使用 fastjson 或 GSON

JSON 是一种轻量级的数据格式,JSON 序列化的优点是可读性比较高,方便调试。我们本篇以 fastjson 的序列化为例,请参考以下代码:


// 序列化和反序列化
class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 对象赋值
User user = new User();
user.setName(“老王”);
user.setAge(30);
System.out.println(user);

        String jsonSerialize = JSON.toJSONString(user);
        User user3 = (User) JSON.parseObject(jsonSerialize, User.class);
        System.out.println(user3);
    }
}
class User implements Serializable {
    private static final long serialVersionUID = 5132320539584511249L;
    private String name;
    private int age;
    @Override
    public String toString() {
        return "{name:" + name + ",age:" + age + "}";
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
}

3)Hessian 方式序列化

Hessian 序列化的优点是可以跨编程语言,比 Java 原生的序列化和反序列化效率高。
请参考以下示例代码:


// 序列化和反序列化
class SerializableTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 序列化
ByteArrayOutputStream bo = new ByteArrayOutputStream();
HessianOutput hessianOutput = new HessianOutput(bo);
hessianOutput.writeObject(user);
byte[] hessianBytes = bo.toByteArray();
// 反序列化
ByteArrayInputStream bi = new ByteArrayInputStream(hessianBytes);
HessianInput hessianInput = new HessianInput(bi);
User user4 = (User) hessianInput.readObject();
System.out.println(user4);
}
}
class User implements Serializable {
private static final long serialVersionUID = 5132320539584511249L;
private String name;
private int age;
@Override
public String toString() {
return “{name:” + name + “,age:” + age + “}”;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}

4.使用克隆有什么好处?

答:好处包含以下几点。

  • 使用方便:假如要复制一个对象,但这个对象中的部分属性已经被修改过了,如果不使用克隆的话,需要给属性手动赋值,相比克隆而已麻烦很多;
  • 性能高:查看 clone 方法可以知道,它是 native 方法,native 方法是原生函数,使用操作系统底层的语言实现的,因此执行效率更高;
  • 隔离性:克隆可以确保对象操作时相互隔离。

clone() 源代码,如下图:

enter image description
here

5.浅克隆和深克隆有什么区别?

答:区别主要在对引用类型的复制上,具体信息如下。

  • 浅克隆:只会复制对象的值类型,而不会复制对象的引用类型;
  • 深克隆:复制整个对象,包含值类型和引用类型。

6.如何实现浅克隆?

答:克隆的对象实现 Cloneable 接口,并重写 clone() 方法就可以实现浅克隆了。

7.以下代码执行的结果是?


import java.util.Arrays;
class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
CloneObj cloneObj = new CloneObj();
cloneObj.name = “老王”;
cloneObj.age = 30;
cloneObj.sistersAge = new int[]{18, 19};
CloneObj cloneObj2 = (CloneObj) cloneObj.clone();
cloneObj2.name = “磊哥”;
cloneObj2.age = 33;
cloneObj2.sistersAge[0] = 20;
System.out.println(cloneObj.name + “|” + cloneObj2.name);
System.out.println(cloneObj.age + “|” + cloneObj2.age);
System.out.println(Arrays.toString(cloneObj.sistersAge) + “|” + Arrays.toString(cloneObj2.sistersAge));
}
}
class CloneObj implements Cloneable {
public String name;
public int age;
public int[] sistersAge;
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

答:执行结果如下。


老王|磊哥
30|33
[20, 19]|[20, 19]

8.深克隆如何实现?有几种实现方式?

答:一般实现方式有两种。

  • 通过序列化实现深克隆(序列化实现方式:Java 原生序列化、JSON 序列化、Hessian 序列化);
  • 所有引用类型都实现克隆,从而实现深克隆。

9.为什么不能直接使用 Object 的 Clone 方法,还要重写 clone() 方法之后才能实现克隆?

答:虽然所有类都是 Object 的子类,但因为 Object 中的 clone() 方法被声明为 protected 访问级别,所以非 java.lang
包下的其他类是不能直接使用的。因此要想实现克隆功能,就必须实现 Cloneable,并重写 clone() 方法才行。

10.序列化可不可以实现深克隆?实现的原理是什么?

答:先将原对象序列化到内存的字节流中,再从字节流中反序列化出刚刚存储的对象,这个新对象和原对象就不存在任何地址上的共享,这样就实现了深克隆。

11.序列化时某些成员不需要序列化,如何实现?

答:可以把不需要序列化的成员设置为瞬态(trasient)和静态变量,这样就不会被序列化了,瞬态的使用如下:

public transient int num;

12.是否可以自定义序列化过程,覆盖 Java 中的默认序列化过程?

答:可以,在 Java 中默认序列化一个对象需要调用 ObjectOutputStream.writeObject(saveThisObject) 和
ObjectInputStream.readObject()
读取对象,你可以自定义这两个方法,从而实现自定义序列化的过程。需要注意的重要一点是,记得声明这些方法为私有方法,以避免被继承、重写或重载。

13.在 Java 中的序列化和反序列化过程中使用了哪些方法?

答:在 Java 中序列化由 java.io.ObjectOutputStream
类完成,该类是一个筛选器流,它封装在较低级别的字节流中,以处理序列化机制。要通过序列化机制存储任何对象,我们需要调用
ObjectOutputStream.writeObject(savethisobject) 方法,如果要反序列化该对象,我们需要调用
ObjectInputStream.readObject() 方法,readObject() 方法会读取字节,并把这些字节转换为对象再返回。

总结

序列化常见的使用场景是远程服务调用(RPC)和网络对象传输等,可通过 implements Serializable
来实现对象序列化,在序列化对象中通过定义 serialVersionUID 来防止执行不兼容的类更改。调用 Object 类中的 clone()
方法默认是浅克隆,浅克隆只能复制值类型,不能复制引用类型,因此更多的时候我们需要深克隆,深克隆通常的实现方式有两种:序列化和所有引用类型都实现克隆。

集合详解之 Collection + 面试题

先来看看集合的继承关系图,如下图所示:

JAVA面试汇总第二章 类和数据结构_第6张图片

其中:

  • 外框为虚线的表示接口,边框为实线的表示类;
  • 箭头为虚线的表示实现了接口,箭头为实线的表示继承了类。

为了方便理解,我隐藏了一些与本文内容无关的信息,隐藏的这些内容会在后面的章节中进行详细地介绍。

从图中可以看出,集合的根节点是 Collection,而 Collection 下又提供了两大常用集合,分别是:

  • List:使用最多的有序集合,提供方便的新增、修改、删除的操作;
  • Set:集合不允许有重复的元素,在许多需要保证元素唯一性的场景中使用。

下面我们分别对集合类进行详细地介绍。

集合使用

1)Vector

Vector 是 Java 早期提供的线程安全的有序集合,如果不需要线程安全,不建议使用此集合,毕竟同步是有线程开销的。

使用示例代码:

Vector vector = new Vector();
vector.add("dog");
vector.add("cat");
vector.remove("cat");
System.out.println(vector);

程序执行结果:[dog]

2)ArrayList

ArrayList
是最常见的非线程安全的有序集合,因为内部是数组存储的,所以随机访问效率很高,但非尾部的插入和删除性能较低,如果在中间插入元素,之后的所有元素都要后移。ArrayList
的使用与 Vector 类似。

3)LinkedList

LinkedList 是使用双向链表数据结构实现的,因此增加和删除效率比较高,而随机访问效率较差。

LinkedList 除了包含以上两个类的操作方法之外,还新增了几个操作方法,如 offer() 、peek() 等,具体详情,请参考以下代码:

LinkedList linkedList = new LinkedList();
// 添加元素
linkedList.offer("bird");
linkedList.push("cat");
linkedList.push("dog");
// 获取第一个元素
System.out.println(linkedList.peek());
// 获取第一个元素,并删除此元素
System.out.println(linkedList.poll());
System.out.println(linkedList);

程序的执行结果:

dog
dog
[cat, bird]

4)HashSet

HashSet 是一个没有重复元素的集合。虽然它是 Set 集合的子类,实际却为 HashMap 的实例,相关源码如下:

public HashSet() {
    map = new HashMap<>();
}

因此 HashSet 是无序集合,没有办法保证元素的顺序性。

HashSet 默认容量为 16,每次扩充 0.75 倍,相关源码如下:

public HashSet(Collection c) {
    map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
    addAll(c);
}

HashSet 的使用与 Vector 类似。

5)TreeSet

TreeSet 集合实现了自动排序,也就是说 TreeSet 会把你插入数据进行自动排序。

示例代码如下:

TreeSet treeSet = new TreeSet();
treeSet.add("dog");
treeSet.add("camel");
treeSet.add("cat");
treeSet.add("ant");
System.out.println(treeSet);

程序执行结果:[ant, camel, cat, dog]

可以看出,TreeSet 的使用与 Vector 类似,只是实现了自动排序。

6)LinkedHashSet

LinkedHashSet 是按照元素的 hashCode
值来决定元素的存储位置,但同时又使用链表来维护元素的次序,这样使得它看起来像是按照插入顺序保存的。

LinkedHashSet 的使用与 Vector 类似。

集合与数组

集合和数组的转换可使用 toArray() 和 Arrays.asList() 来实现,请参考以下代码示例:

List list = new ArrayList();
list.add("cat");
list.add("dog");
// 集合转数组
String[] arr = list.toArray(new String[list.size()]);
// 数组转集合
List list2 = Arrays.asList(arr);

集合与数组的区别,可以参考「数组和排序算法的应用 +
面试题」的内容。

集合排序

在 Java 语言中排序提供了两种方式:Comparable 和 Comparator,它们的区别也是常见的面试题之一。下面我们彻底地来了解一下
Comparable 和 Comparator 的使用与区别。

1)Comparable

Comparable 位于 java.lang 包下,是一个排序接口,也就是说如果一个类实现了 Comparable 接口,就意味着该类有了排序功能。

Comparable 接口只包含了一个函数,定义如下:

package java.lang;
import java.util.*;
public interface Comparable {
  public int compareTo(T o);
}

Comparable 使用示例 ,请参考以下代码:

class ComparableTest {
    public static void main(String[] args) {
        Dog[] dogs = new Dog[]{
                new Dog("老旺财", 10),
                new Dog("小旺财", 3),
                new Dog("二旺财", 5),
        };
        // Comparable 排序
        Arrays.sort(dogs);
        for (Dog d : dogs) {
            System.out.println(d.getName() + ":" + d.getAge());
        }
    }
}
class Dog implements Comparable {
    private String name;
    private int age;
    @Override
    public int compareTo(Dog o) {
        return age - o.age;
    }
    public Dog(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
}

程序执行结果:

小旺财:3
二旺财:5
老旺财:10

如果 Dog 类未实现 Comparable 执行代码会报程序异常的信息,错误信息为:

Exception in thread “main” java.lang.ClassCastException: xxx cannot be cast
to java.lang.Comparable

compareTo() 返回值有三种:

  • e1.compareTo(e2) > 0 即 e1 > e2;
  • e1.compareTo(e2) = 0 即 e1 = e2;
  • e1.compareTo(e2) < 0 即 e1 < e2。

2)Comparator

Comparator 是一个外部比较器,位于 java.util 包下,之所以说 Comparator 是一个外部比较器,是因为它无需在比较类中实现
Comparator 接口,而是要新创建一个比较器类来进行比较和排序。

Comparator 接口包含的主要方法为 compare(),定义如下:

public interface Comparator {
  int compare(T o1, T o2);
}

Comparator 使用示例 ,请参考以下代码:

class ComparatorTest {
    public static void main(String[] args) {
        Dog[] dogs = new Dog[]{
                new Dog("老旺财", 10),
                new Dog("小旺财", 3),
                new Dog("二旺财", 5),
        };
        // Comparator 排序
        Arrays.sort(dogs,new DogComparator());
        for (Dog d : dogs) {
            System.out.println(d.getName() + ":" + d.getAge());
        }
    }
}
class DogComparator implements Comparator {
    @Override
    public int compare(Dog o1, Dog o2) {
        return o1.getAge() - o2.getAge();
    }
}
class Dog {
    private String name;
    private int age;
    public Dog(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
}

程序执行结果:

小旺财:3
二旺财:5
老旺财:10

相关面试题

1.List 和 Set 有什么区别?

答:区别分为以下几个方面:

  • List 允许有多个 null 值,Set 只允许有一个 null 值;
  • List 允许有重复元素,Set 不允许有重复元素;
  • List 可以保证每个元素的存储顺序,Set 无法保证元素的存储顺序。

2.哪种集合可以实现自动排序?

答:TreeSet 集合实现了元素的自动排序,也就是说无需任何操作,即可实现元素的自动排序功能。

3.Vector 和 ArrayList 初始化大小和容量扩充有什么区别?

答:Vector 和 ArrayList 的默认容量都为 10,源码如下。

Vector 默认容量源码:

public Vector() {
    this(10);
}

ArrayList 默认容量源码:

private static final int DEFAULT_CAPACITY = 10;

Vector 容量扩充默认增加 1 倍,源码如下:

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                     capacityIncrement : oldCapacity);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);
}

其中 capacityIncrement 为初始化 Vector 指定的,默认情况为 0。

ArrayList 容量扩充默认增加大概 0.5 倍(oldCapacity + (oldCapacity >> 1)),源码如下(JDK 8):

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

4.Vector、ArrayList、LinkedList 有什么区别?

答:这三者都是 List 的子类,因此功能比较相似,比如增加和删除操作、查找元素等,但在性能、线程安全等方面表现却又不相同,差异如下:

  • Vector 是 Java 早期提供的动态数组,它使用 synchronized 来保证线程安全,如果非线程安全需要不建议使用,毕竟线程同步是有性能开销的;
  • ArrayList 是最常用的动态数组,本身并不是线程安全的,因此性能要好很多,与 Vector 类似,它也是动态调整容量的,只不过 Vector 扩容时会增加 1 倍,而 ArrayList 会增加 50%;
  • LinkedList 是双向链表集合,因此它不需要像上面两种那样调整容量,它也是非线程安全的集合。

5.Vector、ArrayList、LinkedList 使用场景有什么区别?

答:Vector 和 ArrayList
的内部结构是以数组形式存储的,因此非常适合随机访问,但非尾部的删除或新增性能较差,比如我们在中间插入一个元素,就需要把后续的所有元素都进行移动。

LinkedList 插入和删除元素效率比较高,但随机访问性能会比以上两个动态数组慢。

6.Collection 和 Collections 有什么区别?

答:Collection 和 Collections 的区别如下:

  • Collection 是集合类的上级接口,继承它的主要有 List 和 Set;
  • Collections 是针对集合类的一个帮助类,它提供了一些列的静态方法实现,如 Collections.sort() 排序、Collections.reverse() 逆序等。

7.以下选项没有继承 Collection 接口的是?

A:List
B:Set
C:Map
D:HashSet

答:C

8.LinkedHashSet 如何保证有序和唯一性?

答:LinkedHashSet 底层数据结构由哈希表和链表组成,链表保证了元素的有序即存储和取出一致,哈希表保证了元素的唯一性。

9.HashSet 是如何保证数据不可重复的?

答:HashSet 的底层其实就是 HashMap,只不过 HashSet 实现了 Set 接口并且把数据作为 K 值,而 V
值一直使用一个相同的虚值来保存,我们可以看到源码:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;// 调用 HashMap 的 put 方法,PRESENT 是一个至始至终都相同的虚值
}

由于 HashMap 的 K 值本身就不允许重复,并且在 HashMap 中如果 K/V 相同时,会用新的 V 覆盖掉旧的 V,然后返回旧的 V,那么在
HashSet 中执行这一句话始终会返回一个 false,导致插入失败,这样就保证了数据的不可重复性。

10.执行以下程序会输出什么结果?为什么?

Integer num = 10;
Integer num2 = 5;
System.out.println(num.compareTo(num2));

答:程序输出的结果是 1,因为 Integer 默认实现了 compareTo 方法,定义了自然排序规则,所以当 num 比 num2 大时会返回
1,Integer 相关源码如下:

public int compareTo(Integer anotherInteger) {
    return compare(this.value, anotherInteger.value);
}
public static int compare(int x, int y) {
    return (x < y) ? -1 : ((x == y) ? 0 : 1);
}

11.如何用程序实现后进先出的栈结构?

答:可以使用集合中的 Stack 实现,Stack 是标准的后进先出的栈结构,使用 Stack 中的 pop()
方法返回栈顶元素并删除该元素,示例代码如下。

Stack stack = new Stack();
stack.push("a");
stack.push("b");
stack.push("c");
for (int i = 0; i < 3; i++) {
    // 移除并返回栈顶元素
    System.out.print(stack.pop() + " ");
}

程序执行结果:c b a

12.LinkedList 中的 peek() 和 poll() 有什么区别?

答:peek() 方法返回第一个元素,但不删除当前元素,当元素不存在时返回 null;poll() 方法返回第一个元素并删除此元素,当元素不存在时返回
null。

13.Comparable 和 Comparator 有哪些区别?

答:Comparable 和 Comparator 的主要区别如下:

  • Comparable 位于 java.lang 包下,而 Comparator 位于 java.util 包下;
  • Comparable 在排序类的内部实现,而 Comparator 在排序类的外部实现;
  • Comparable 需要重写 CompareTo() 方法,而 Comparator 需要重写 Compare() 方法;
  • Comparator 在类的外部实现,更加灵活和方便。

总结

本文介绍的集合都实现自 Collection,因此它们都有同样的操作方法,如 add()、addAll()、remove() 等,Collection
接口的方法列表如下图:

JAVA面试汇总第二章 类和数据结构_第7张图片

当然部分集合也在原有方法上扩充了自己特有的方法,如 LinkedList 的 offer()、push()
等方法。本文也提供了数组和集合互转方法,List.toArray() 把集合转换为数组,Arrays.asList(array)
把数组转换为集合。最后介绍了 Comparable 和 Comparator 的使用和区别,Comparable 和 Comparator 是 Java
语言排序提供的两种排序方式,Comparable 位于 java.lang 包下,如果一个类实现了 Comparable 接口,就意味着该类有了排序功能;而
Comparator 位于 java.util 包下,是一个外部比较器,它无需在比较类中实现 Comparator
接口,而是要新创建一个比较器类来进行比较和排序。

集合详解之 Map + 面试题

集合有两个大接口:Collection 和 Map,本文重点来讲解集合中另一个常用的集合类型 Map。

以下是 Map 的继承关系图:

JAVA面试汇总第二章 类和数据结构_第8张图片

Map 简介

Map 常用的实现类如下:

  • Hashtable :Java 早期提供的一个哈希表实现,它是线程安全的,不支持 null 键和值,因为它的性能不如 ConcurrentHashMap,所以很少被推荐使用。
  • HashMap :最常用的哈希表实现,如果程序中没有多线程的需求,HashMap 是一个很好的选择,支持 null 键和值,如果在多线程中可用 ConcurrentHashMap 替代。
  • TreeMap :基于红黑树的一种提供顺序访问的 Map,自身实现了 key 的自然排序,也可以指定 Comparator 来自定义排序。
  • LinkedHashMap :HashMap 的一个子类,保存了记录的插入顺序,可在遍历时保持与插入一样的顺序。

Map 常用方法

常用方法包括:put、remove、get、size 等,所有方法如下图:

JAVA面试汇总第二章 类和数据结构_第9张图片

使用示例,请参考以下代码:

Map hashMap = new HashMap();
// 增加元素
hashMap.put("name", "老王");
hashMap.put("age", "30");
hashMap.put("sex", "你猜");
// 删除元素
hashMap.remove("age");
// 查找单个元素
System.out.println(hashMap.get("age"));
// 循环所有的 key
for (Object k : hashMap.keySet()) {
    System.out.println(k);
}
// 循环所有的值
for (Object v : hashMap.values()) {
    System.out.println(v);
}

以上为 HashMap 的使用示例,其他类的使用也是类似。

HashMap 数据结构

HashMap 底层的数据是数组被成为哈希桶,每个桶存放的是链表,链表中的每个节点,就是 HashMap 中的每个元素。在 JDK 8 当链表长度大于等于
8 时,就会转成红黑树的数据结构,以提升查询和插入的效率。

HashMap 数据结构,如下图:

JAVA面试汇总第二章 类和数据结构_第10张图片

HashMap 重要方法

1)添加方法:put(Object key, Object value)

执行流程如下:

  • 对 key 进行 hash 操作,计算存储 index;
  • 判断是否有哈希碰撞,如果没碰撞直接放到哈希桶里,如果有碰撞则以链表的形式存储;
  • 判断已有元素的类型,决定是追加树还是追加链表,当链表大于等于 8 时,把链表转换成红黑树;
  • 如果节点已经存在就替换旧值;
  • 判断是否超过阀值,如果超过就要扩容。

源码及说明:

public V put(K key, V value) {
    // 对 key 进行 hash()
    return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
    int h;
  // 对 key 进行 hash() 的具体实现
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node[] tab; Node p; int n, i;
    // tab为空则创建
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 计算 index,并对 null 做处理
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node e; K k;
        // 节点存在
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 该链为树
        else if (p instanceof TreeNode)
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
        // 该链为链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 写入
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 超过load factor*current capacity,resize
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

put() 执行流程图如下:

JAVA面试汇总第二章 类和数据结构_第11张图片

2)获取方法:get(Object key)

执行流程如下:

  • 首先比对首节点,如果首节点的 hash 值和 key 的 hash 值相同,并且首节点的键对象和 key 相同(地址相同或 equals 相等),则返回该节点;
  • 如果首节点比对不相同、那么看看是否存在下一个节点,如果存在的话,可以继续比对,如果不存在就意味着 key 没有匹配的键值对。

源码及说明:

public V get(Object key) {
  Node e;
  return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 该方法是 Map.get 方法的具体实现
* 接收两个参数
* @param hash key 的 hash 值,根据 hash 值在节点数组中寻址,该 hash 值是通过 hash(key) 得到的
* @param key key 对象,当存在 hash 碰撞时,要逐个比对是否相等
* @return 查找到则返回键值对节点对象,否则返回 null
*/
final Node getNode(int hash, Object key) {
    Node[] tab; Node first, e; int n; K k; // 声明节点数组对象、链表的第一个节点对象、循环遍历时的当前节点对象、数组长度、节点的键对象
    // 节点数组赋值、数组长度赋值、通过位运算得到求模结果确定链表的首节点
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // 首先比对首节点,如果首节点的 hash 值和 key 的 hash 值相同,并且首节点的键对象和 key 相同(地址相同或 equals 相等),则返回该节点
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first; // 返回首节点

        // 如果首节点比对不相同、那么看看是否存在下一个节点,如果存在的话,可以继续比对,如果不存在就意味着 key 没有匹配的键值对    
        if ((e = first.next) != null) {
            // 如果存在下一个节点 e,那么先看看这个首节点是否是个树节点
            if (first instanceof TreeNode)
                // 如果是首节点是树节点,那么遍历树来查找
                return ((TreeNode)first).getTreeNode(hash, key); 

            // 如果首节点不是树节点,就说明还是个普通的链表,那么逐个遍历比对即可    
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k)))) // 比对时还是先看 hash 值是否相同、再看地址或 equals
                    return e; // 如果当前节点e的键对象和key相同,那么返回 e
            } while ((e = e.next) != null); // 看看是否还有下一个节点,如果有,继续下一轮比对,否则跳出循环
        }
    }
    return null; // 在比对完了应该比对的树节点 或者全部的链表节点 都没能匹配到 key,那么就返回 null

相关面试题

1.Map 常见实现类有哪些?

答:Map 的常见实现类如下列表:

  • Hashtable:Java 早期提供的一个哈希表实现,它是线程安全的,不支持 null 键和值,因为它的性能不如 ConcurrentHashMap,所以很少被推荐使用;
  • HashMap:最常用的哈希表实现,如果程序中没有多线程的需求,HashMap 是一个很好的选择,支持 null 键和值,如果在多线程中可用 ConcurrentHashMap 替代;
  • TreeMap:基于红黑树的一种提供顺序访问的 Map,自身实现了 key 的自然排序,也可以指定的 Comparator 来自定义排序;
  • LinkedHashMap:HashMap 的一个子类,保存了记录的插入顺序,可在遍历时保持与插入一样的顺序。

2.使用 HashMap 可能会遇到什么问题?如何避免?

答:HashMap 在并发场景中可能出现死循环的问题,这是因为 HashMap
在扩容的时候会对链表进行一次倒序处理,假设两个线程同时执行扩容操作,第一个线程正在执行 B→A 的时候,第二个线程又执行了 A→B ,这个时候就会出现
B→A→B 的问题,造成死循环。
解决的方法:升级 JDK 版本,在 JDK 8 之后扩容不会再进行倒序,因此死循环的问题得到了极大的改善,但这不是终极的方案,因为 HashMap
本来就不是用在多线程版本下的,如果是多线程可使用 ConcurrentHashMap 替代 HashMap。

3.以下说法正确的是?

A:Hashtable 和 HashMap 都是非线程安全的
B:ConcurrentHashMap 允许 null 作为 key
C:HashMap 允许 null 作为 key
D:Hashtable 允许 null 作为 key
答:C
题目解析:Hashtable 是线程安全的,ConcurrentHashMap 和 Hashtable 是不允许 null 作为键和值的。

4.TreeMap 怎么实现根据 value 值倒序?

答:使用 Collections.sort(list, new Comparator>()
自定义比较器实现,先把 TreeMap 转换为 ArrayList,在使用 Collections.sort() 根据 value
进行倒序,完整的实现代码如下。

TreeMap treeMap = new TreeMap();
treeMap.put("dog", "dog");
treeMap.put("camel", "camel");
treeMap.put("cat", "cat");
treeMap.put("ant", "ant");
// map.entrySet() 转成 List
List> list = new ArrayList<>(treeMap.entrySet());
// 通过比较器实现比较排序
Collections.sort(list, new Comparator>() {
  public int compare(Map.Entry m1, Map.Entry m2) {
    return m2.getValue().compareTo(m1.getValue());
  }
});
// 打印结果
for (Map.Entry item : list) {
  System.out.println(item.getKey() + ":" + item.getValue());
}

程序执行结果:

dog:dog
cat:cat
camel:camel
ant:ant

5.以下哪个 Set 实现了自动排序?

A:LinedHashSet
B:HashSet
C:TreeSet
D:AbstractSet

答:C

6.以下程序运行的结果是什么?

Hashtable hashtable = new Hashtable();
hashtable.put("table", null);
System.out.println(hashtable.get("table"));

答:程序执行报错:java.lang.NullPointerException。Hashtable 不允许 null 键和值。

7.HashMap 有哪些重要的参数?用途分别是什么?

答:HashMap 有两个重要的参数:容量(Capacity)和负载因子(LoadFactor)。

  • 容量(Capacity):是指 HashMap 中桶的数量,默认的初始值为 16。
  • 负载因子(LoadFactor):也被称为装载因子,LoadFactor 是用来判定 HashMap 是否扩容的依据,默认值为 0.75f,装载因子的计算公式 = HashMap 存放的 KV 总和(size)/ Capacity。

8.HashMap 和 Hashtable 有什么区别?

答:HashMap 和 Hashtable 区别如下:

  • Hashtable 使用了 synchronized 关键字来保障线程安全,而 HashMap 是非线程安全的;
  • HashMap 允许 K/V 都为 null,而 Hashtable K/V 都不允许 null;
  • HashMap 继承自 AbstractMap 类;而 Hashtable 继承自 Dictionary 类。

9.什么是哈希冲突?

答:当输入两个不同值,根据同一散列函数计算出相同的散列值的现象,我们就把它叫做碰撞(哈希碰撞)。

10.有哪些方法可以解决哈希冲突?

答:哈希冲突的常用解决方案有以下 4 种。

  • 开放定址法:当关键字的哈希地址 p=H(key)出现冲突时,以 p 为基础,产生另一个哈希地址 p1,如果 p1 仍然冲突,再以 p 为基础,产生另一个哈希地址 p2,循环此过程直到找出一个不冲突的哈希地址,将相应元素存入其中。
  • 再哈希法:这种方法是同时构造多个不同的哈希函数,当哈希地址 Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key),循环此过程直到找到一个不冲突的哈希地址,这种方法唯一的缺点就是增加了计算时间。
  • 链地址法:这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。
  • 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

11.HashMap 使用哪种方法来解决哈希冲突(哈希碰撞)?

答:HashMap 使用链表和红黑树来解决哈希冲突,详见本文 put() 方法的执行过程。

12.HashMap 的扩容为什么是 2^n ?

答:这样做的目的是为了让散列更加均匀,从而减少哈希碰撞,以提供代码的执行效率。

13.有哈希冲突的情况下 HashMap 如何取值?

答:如果有哈希冲突,HashMap 会循环链表中的每项 key 进行 equals 对比,返回对应的元素。相关源码如下:

do {
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k)))) // 比对时还是先看 hash 值是否相同、再看地址或 equals
        return e; // 如果当前节点 e 的键对象和 key 相同,那么返回 e
} while ((e = e.next) != null); // 看看是否还有下一个节点,如果有,继续下一轮比对,否则跳出循环

14.以下程序会输出什么结果?

class Person {
    private Integer age;
    public boolean equals(Object o) {
        if (o == null || !(o instanceof Person)) {
            return false;
        } else {
            return this.getAge().equals(((Person) o).getAge());
        }
    }
    public int hashCode() {
        return age.hashCode();
    }
    public Person(int age) {
        this.age = age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public Integer getAge() {
        return age;
    }
    public static void main(String[] args) {
        HashMap hashMap = new HashMap<>();
        Person person = new Person(18);
        hashMap.put(person, 1);
        System.out.println(hashMap.get(new Person(18)));
    }
}

答:1
题目解析:因为 Person 重写了 equals 和 hashCode 方法,所有 person 对象和 new Person(18)
的键值相同,所以结果就是 1。

15.为什么重写 equals() 时一定要重写 hashCode()?

答:因为 Java 规定,如果两个对象 equals 比较相等(结果为 true),那么调用 hashCode 也必须相等。如果重写了 equals()
但没有重写 hashCode(),就会与规定相违背,比如以下代码(故意注释掉 hashCode 方法):

class Person {
    private Integer age;
    public boolean equals(Object o) {
        if (o == null || !(o instanceof Person)) {
            return false;
        } else {
            return this.getAge().equals(((Person) o).getAge());
        }
    }
//    public int hashCode() {
//        return age.hashCode();
//    }
    public Person(int age) {
        this.age = age;
    }
    public void setAge(int age) {
        this.age = age;
    }
    public Integer getAge() {
        return age;
    }
    public static void main(String[] args) {
        Person p1 = new Person(18);
        Person p2 = new Person(18);
        System.out.println(p1.equals(p2));
        System.out.println(p1.hashCode() + " : " + p2.hashCode());
    }
}

执行的结果:

true
21685669 : 2133927002

如果重写 hashCode() 之后,执行的结果是:

true
18 : 18

这样就符合了 Java 的规定,因此重写 equals() 时一定要重写 hashCode()。

16.HashMap 在 JDK 7 多线程中使用会导致什么问题?

答:HashMap 在 JDK 7 中会导致死循环的问题。因为在 JDK 7 中,多线程进行 HashMap 扩容时会导致链表的循环引用,这个时候使用
get() 获取元素时就会导致死循环,造成 CPU 100% 的情况。

17.HashMap 在 JDK 7 和 JDK 8 中有哪些不同?

答:HashMap 在 JDK 7 和 JDK 8 的主要区别如下。

  • 存储结构:JDK 7 使用的是数组 + 链表;JDK 8 使用的是数组 + 链表 + 红黑树。
  • 存放数据的规则:JDK 7 无冲突时,存放数组;冲突时,存放链表;JDK 8 在没有冲突的情况下直接存放数组,有冲突时,当链表长度小于 8 时,存放在单链表结构中,当链表长度大于 8 时,树化并存放至红黑树的数据结构中。
  • 插入数据方式:JDK 7 使用的是头插法(先将原位置的数据移到后 1 位,再插入数据到该位置);JDK 8 使用的是尾插法(直接插入到链表尾部/红黑树)。

总结

通过本文可以了解到:

  • Map 的常用实现类 Hashtable 是 Java 早期的线程安全的哈希表实现;
  • HashMap 是最常用的哈希表实现,但它是非线程安全的,可使用 ConcurrentHashMap 替代;
  • TreeMap 是基于红黑树的一种提供顺序访问的哈希表实现;
  • LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,可在遍历时保持与插入一样的顺序。

HashMap 在 JDK 7 可能在扩容时会导致链表的循环引用而造成 CPU 100%,HashMap 在 JDK 8 时数据结构变更为:数组 + 链表

  • 红黑树的存储方式,在没有冲突的情况下直接存放数组,有冲突,当链表长度小于 8 时,存放在单链表结构中,当链表长度大于 8
    时,树化并存放至红黑树的数据结构中。

为什么要使用泛型和迭代器 + 面试题

泛型

1)为什么要用泛型?

在泛型没有诞生之前,我们经常会遇到这样的问题,如以下代码所示:

ArrayList arrayList = new ArrayList();
arrayList.add("Java");
arrayList.add(24);
for (int i = 0; i < arrayList.size(); i++) {
    String str = (String) arrayList.get(i);
    System.out.println(str);
}

看起来好像没有什么大问题,也能正常编译,但真正运行起来就会报错:

Exception in thread “main” java.lang.ClassCastException: java.lang.Integer
cannot be cast to java.lang.String

at xxx(xxx.java:12)

类型转换出错,当我们给 ArrayList
放入不同类型的数据,却使用一种类型进行接收的时候,就会出现很多类似的错误,可能更多的时候,是因为开发人员的不小心导致的。那有没有好的办法可以杜绝此类问题的发生呢?这个时候
Java 语言提供了一个很好的解决方案——“泛型”。

2)泛型介绍

泛型 :泛型本质上是类型参数化,解决了不确定对象的类型问题。
泛型的使用,请参考以下代码:

ArrayList arrayList = new ArrayList();
arrayList.add("Java");

这个时候如果给 arrayList 添加非 String 类型的元素,编译器就会报错,提醒开发人员插入相同类型的元素。

报错信息如下图所示:

JAVA面试汇总第二章 类和数据结构_第12张图片

这样就可以避免开头示例中,类型不一致导致程序运行过程中报错的问题了。

3)泛型的优点

泛型的优点主要体现在以下三个方面。

  • 安全:不用担心程序运行过程中出现类型转换的错误。
  • 避免了类型转换:如果是非泛型,获取到的元素是 Object 类型的,需要强制类型转换。
  • 可读性高:编码阶段就明确的知道集合中元素的类型。

迭代器(Iterator)

1)为什么要用迭代器?

我们回想一下,在迭代器(Iterator)没有出现之前,如果要遍历数组和集合,需要使用方法。

数组遍历,代码如下:

String[] arr = new String[]{"Java", "Java虚拟机", "Java中文社群"};
for (int i = 0; i < arr.length; i++) {
    String item = arr[i];
}

集合遍历,代码如下:

List list = new ArrayList() {{
    add("Java");
    add("Java虚拟机");
    add("Java中文社群");
}};
for (int i = 0; i < list.size(); i++) {
    String item = list.get(i);
}

而迭代器的产生,就是为不同类型的容器遍历,提供标准统一的方法。

迭代器遍历,代码如下:

Iterator iterator = list.iterator();
while (iterator.hasNext()) {
    Object object = iterator.next();
    // do something
}

总结 :使用了迭代器就可以不用关注容器的内部细节,用同样的方式遍历不同类型的容器。

2)迭代器介绍

迭代器是用来遍历容器内所有元素对象的,也是一种常见的设计模式。

迭代器包含以下四个方法。

  • hasNext():boolean —— 容器内是否还有可以访问的元素。
  • next():E —— 返回下一个元素。
  • remove():void —— 删除当前元素。
  • forEachRemaining(Consumer super E>):void —— JDK 8 中添加的,提供一个 lambda 表达式遍历容器元素。

迭代器使用如下:

List list = new ArrayList() {{
    add("Java");
    add("Java虚拟机");
    add("Java中文社群");
}};
Iterator iterator =  list.iterator();
// 遍历
while (iterator.hasNext()){
    String str = (String) iterator.next();
    if (str.equals("Java中文社群")){
        iterator.remove();
    }
}
System.out.println(list);

程序执行结果:

[Java, Java虚拟机]

forEachRemaining 使用如下:

List list = new ArrayList() {{
    add("Java");
    add("Java虚拟机");
    add("Java中文社群");
}};
// forEachRemaining 使用
list.iterator().forEachRemaining(item -> System.out.println(item));

相关面试题

1.为什么迭代器的 next() 返回的是 Object 类型?

答:因为迭代器不需要关注容器的内部细节,所以 next() 返回 Object 类型就可以接收任何类型的对象。

2.HashMap 的遍历方式都有几种?

答:HashMap 的遍历分为以下四种方式。

  • 方式一:entrySet 遍历
  • 方式二:iterator 遍历
  • 方式三:遍历所有的 key 和 value
  • 方式四:通过 key 值遍历

以上方式的代码实现如下:

Map hashMap = new HashMap();
hashMap.put("name", "老王");
hashMap.put("sex", "你猜");
// 方式一:entrySet 遍历
for (Map.Entry item : hashMap.entrySet()) {
  System.out.println(item.getKey() + ":" + item.getValue());
}
// 方式二:iterator 遍历
Iterator> iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
  Map.Entry entry = iterator.next();
  System.out.println(entry.getKey() + ":" + entry.getValue());
}
// 方式三:遍历所有的 key 和 value
for (Object k : hashMap.keySet()) {
  // 循环所有的 key
  System.out.println(k);
}
for (Object v : hashMap.values()) {
  // 循环所有的值
  System.out.println(v);
}
// 方式四:通过 key 值遍历
for (Object k : hashMap.keySet()) {
  System.out.println(k + ":" + hashMap.get(k));
}

3.以下关于泛型说法错误的是?

A:泛型可以修饰类
B:泛型可以修饰方法
C:泛型不可以修饰接口
D:以上说法全错

答:选 C,泛型可以修饰类、方法、接口、变量。
例如:

public interface Iterable {
}

4.以下程序执行的结果是什么?

List list = new ArrayList<>();
List list2 = new ArrayList<>();
System.out.println(list.getClass() == list2.getClass());

答:程序的执行结果是 true
题目解析:Java 中泛型在编译时会进行类型擦除,因此 List listList list2
类型擦除后的结果都是 java.util.ArrayLis ,进而 list.getClass() == list2.getClass() 的结果也一定是
true。

5. ListList 有什么区别?

答:List 可以容纳任意类型,只不过 List 被赋值之后,就不允许添加和修改操作了;而 List
List 不同的是它在赋值之后,可以进行添加和修改操作,如下图所示:

JAVA面试汇总第二章 类和数据结构_第13张图片

6.可以把 List 赋值给 List 吗?

答:不可以,编译器会报错,如下图所示:

JAVA面试汇总第二章 类和数据结构_第14张图片

7. ListList 的区别是什么?

答: ListList 都能存储任意类型的数据,但 ListList
的唯一区别就是,List 不会触发编译器的类型安全检查,比如把 List 赋值给 List 是没有任何问题的,但赋值给
List 就不行,如下图所示:

JAVA面试汇总第二章 类和数据结构_第15张图片

8.以下程序执行的结果是?

List list = new ArrayList<>();
list.add("Java");
list.add("Java虚拟机");
list.add("Java中文社群");
Iterator iterator = list.iterator();
while (iterator.hasNext()) {
    String str = (String) iterator.next();
    if (str.equals("Java中文社群")) {
        iterator.remove();
    }
}
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}
System.out.println("Over");

答:程序打印结果是 Over
题目解析:因为第一个 while 循环之后,iterator.hasNext() 返回值就为 false 了,所以不会进入第二个循环,之后打印最后的
Over。

9.泛型的工作原理是什么?为什么要有类型擦除?

答:泛型是通过类型擦除来实现的,类型擦除指的是编译器在编译时,会擦除了所有类型相关的信息,比如 List 在编译后就会变成 List
类型,这样做的目的就是确保能和 Java 5 之前的版本(二进制类库)进行兼容。

总结

通过本文知道了泛型的优点:安全性、避免类型转换、提高了代码的可读性。泛型的本质是类型参数化,但编译之后会执行类型擦除,这样就可以和 Java 5
之前的二进制类库进行兼容。本文也介绍了迭代器(Iterator)的使用,使用迭代器的好处是不用关注容器的内部细节,用同样的方式遍历不同类型的容器。

数据结构之队列的使用 + 面试题

队列(Queue):与栈相对的一种数据结构,
集合(Collection)的一个子类。队列允许在一端进行插入操作,而在另一端进行删除操作的线性表,栈的特点是后进先出,而队列的特点是先进先出。队列的用处很大,比如实现消息队列。

Queue 类关系图,如下图所示:

JAVA面试汇总第二章 类和数据结构_第16张图片

注:为了让读者更直观地理解,上图为精简版的 Queue 类关系图。本文如无特殊说明,内容都是基于 Java 1.8 版本。

队列(Queue)

1)Queue 分类

从上图可以看出 Queue 大体可分为以下三类。

  • 双端队列:双端队列(Deque)是 Queue 的子类也是 Queue 的补充类,头部和尾部都支持元素插入和获取。
  • 阻塞队列:阻塞队列指的是在元素操作时(添加或删除),如果没有成功,会阻塞等待执行。例如,当添加元素时,如果队列元素已满,队列会阻塞等待直到有空位时再插入。
  • 非阻塞队列:非阻塞队列和阻塞队列相反,会直接返回操作的结果,而非阻塞等待。双端队列也属于非阻塞队列。

2)Queue 方法说明

Queue 常用方法,如下图所示:

JAVA面试汇总第二章 类和数据结构_第17张图片

方法说明:

  • add(E):添加元素到队列尾部,成功返回 true,队列超出时抛出异常;
  • offer(E):添加元素到队列尾部,成功返回 true,队列超出时返回 false;
  • remove():删除元素,成功返回 true,失败返回 false;
  • poll():获取并移除此队列的第一个元素,若队列为空,则返回 null;
  • peek():获取但不移除此队列的第一个元素,若队列为空,则返回 null;
  • element():获取但不移除此队列的第一个元素,若队列为空,则抛异常。

3)Queue 使用实例

Queue linkedList = new LinkedList<>();
linkedList.add("Dog");
linkedList.add("Camel");
linkedList.add("Cat");
while (!linkedList.isEmpty()) {
    System.out.println(linkedList.poll());
}

程序执行结果:

Dog

Camel

Cat

阻塞队列

1)BlockingQueue

BlockingQueue 在 java.util.concurrent 包下,其他阻塞类都实现自 BlockingQueue
接口,BlockingQueue
提供了线程安全的队列访问方式,当向队列中插入数据时,如果队列已满,线程则会阻塞等待队列中元素被取出后再插入;当从队列中取数据时,如果队列为空,则线程会阻塞等待队列中有新元素再获取。

BlockingQueue 核心方法

插入方法:

  • add(E):添加元素到队列尾部,成功返回 true,队列超出时抛出异常;
  • offer(E):添加元素到队列尾部,成功返回 true,队列超出时返回 false ;
  • put(E):将元素插入到队列的尾部,如果该队列已满,则一直阻塞。 删除方法:
  • remove(Object):移除指定元素,成功返回 true,失败返回 false;
  • poll(): 获取并移除队列的第一个元素,如果队列为空,则返回 null;
  • take():获取并移除队列第一个元素,如果没有元素则一直阻塞。 检查方法:
  • peek():获取但不移除队列的第一个元素,若队列为空,则返回 null。

2)LinkedBlockingQueue

LinkedBlockingQueue 是一个由链表实现的有界阻塞队列,容量默认值为
Integer.MAX_VALUE,也可以自定义容量,建议指定容量大小,默认大小在添加速度大于删除速度情况下有造成内存溢出的风险,LinkedBlockingQueue
是先进先出的方式存储元素。

3)ArrayBlockingQueue

ArrayBlockingQueue
是一个有边界的阻塞队列,它的内部实现是一个数组。它的容量是有限的,我们必须在其初始化的时候指定它的容量大小,容量大小一旦指定就不可改变。

ArrayBlockingQueue 也是先进先出的方式存储数据,ArrayBlockingQueue 内部的阻塞队列是通过重入锁 ReenterLock
和 Condition 条件队列实现的,因此 ArrayBlockingQueue
中的元素存在公平访问与非公平访问的区别,对于公平访问队列,被阻塞的线程可以按照阻塞的先后顺序访问队列,即先阻塞的线程先访问队列。而非公平队列,当队列可用时,阻塞的线程将进入争夺访问资源的竞争中,也就是说谁先抢到谁就执行,没有固定的先后顺序。

示例代码如下:

// 默认非公平阻塞队列
ArrayBlockingQueue queue = new ArrayBlockingQueue(6);
// 公平阻塞队列
ArrayBlockingQueue queue2 = new ArrayBlockingQueue(6,true);

// ArrayBlockingQueue 源码展示
public ArrayBlockingQueue(int capacity) {
    this(capacity, false);
}
public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

4)DelayQueue

DelayQueue 是一个支持延时获取元素的无界阻塞队列,队列中的元素必须实现 Delayed
接口,在创建元素时可以指定延迟时间,只有到达了延迟的时间之后,才能获取到该元素。

实现了 Delayed 接口必须重写两个方法 ,getDelay(TimeUnit) 和 compareTo(Delayed),如下代码所示:

class DelayElement implements Delayed {
        @Override
        // 获取剩余时间
        public long getDelay(TimeUnit unit) {
            // do something
        }
        @Override
        // 队列里元素的排序依据
        public int compareTo(Delayed o) {
            // do something
        }
    }

DelayQueue 使用的完整示例 ,请参考以下代码:

public class DelayTest {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue delayQueue = new DelayQueue();
        delayQueue.put(new DelayElement(1000));
        delayQueue.put(new DelayElement(3000));
        delayQueue.put(new DelayElement(5000));
        System.out.println("开始时间:" +  DateFormat.getDateTimeInstance().format(new Date()));
        while (!delayQueue.isEmpty()){
            System.out.println(delayQueue.take());
        }
        System.out.println("结束时间:" +  DateFormat.getDateTimeInstance().format(new Date()));
    }

    static class DelayElement implements Delayed {
        // 延迟截止时间(单面:毫秒)
        long delayTime = System.currentTimeMillis();
        public DelayElement(long delayTime) {
            this.delayTime = (this.delayTime + delayTime);
        }
        @Override
        // 获取剩余时间
        public long getDelay(TimeUnit unit) {
            return unit.convert(delayTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }
        @Override
        // 队列里元素的排序依据
        public int compareTo(Delayed o) {
            if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS)) {
                return 1;
            } else if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS)) {
                return -1;
            } else {
                return 0;
            }
        }
        @Override
        public String toString() {
            return DateFormat.getDateTimeInstance().format(new Date(delayTime));
        }
    }
}

程序执行结果:

开始时间:2019-6-13 20:40:38

2019-6-13 20:40:39

2019-6-13 20:40:41

2019-6-13 20:40:43

结束时间:2019-6-13 20:40:43

非阻塞队列

ConcurrentLinkedQueue
是一个基于链接节点的无界线程安全队列,它采用先进先出的规则对节点进行排序,当我们添加一个元素的时候,它会添加到队列的尾部;当我们获取一个元素时,它会返回队列头部的元素。

它的入队和出队操作均利用 CAS(Compare And Set)更新,这样允许多个线程并发执行,并且不会因为加锁而阻塞线程,使得并发性能更好。

ConcurrentLinkedQueue 使用示例:

ConcurrentLinkedQueue concurrentLinkedQueue = new ConcurrentLinkedQueue();
concurrentLinkedQueue.add("Dog");
concurrentLinkedQueue.add("Cat");
while (!concurrentLinkedQueue.isEmpty()) {
    System.out.println(concurrentLinkedQueue.poll());
}

执行结果:

Dog

Cat

可以看出不管是阻塞队列还是非阻塞队列,使用方法都是类似的,区别是底层的实现方式。

优先级队列

PriorityQueue 一个基于优先级堆的无界优先级队列。优先级队列的元素按照其自然顺序进行排序,或者根据构造队列时提供的 Comparator
进行排序,具体取决于所使用的构造方法。优先级队列不允许使用 null 元素。

PriorityQueue 代码使用示例

Queue priorityQueue = new PriorityQueue(new Comparator() {
    @Override
    public int compare(Integer o1, Integer o2) {
        // 非自然排序,数字倒序
        return o2 - o1;
    }
});
priorityQueue.add(3);
priorityQueue.add(1);
priorityQueue.add(2);
while (!priorityQueue.isEmpty()) {
    Integer i = priorityQueue.poll();
    System.out.println(i);
}

程序执行的结果是:

3

2

1

PriorityQueue 注意的点

  • PriorityQueue 是非线程安全的,在多线程情况下可使用 PriorityBlockingQueue 类替代;
  • PriorityQueue 不允许插入 null 元素。

相关面试题

1.ArrayBlockingQueue 和 LinkedBlockingQueue 的区别是什么?

答:ArrayBlockingQueue 和 LinkedBlockingQueue 都实现自阻塞队列
BlockingQueue,它们的区别主要体现在以下几个方面:

  • ArrayBlockingQueue 使用时必须指定容量值,LinkedBlockingQueue 可以不用指定;
  • ArrayBlockingQueue 的最大容量值是使用时指定的,并且指定之后就不允许修改;而 LinkedBlockingQueue 最大的容量为 Integer.MAX_VALUE;
  • ArrayBlockingQueue 数据存储容器是采用数组存储的;而 LinkedBlockingQueue 采用的是 Node 节点存储的。

2.LinkedList 中 add() 和 offer() 有什么关系?

答:add() 和 offer() 都是添加元素到队列尾部。offer 方法是基于 add 方法实现的,Offer 的源码如下:

public boolean offer(E e) {
    return add(e);
}

3.Queue 和 Deque 有什么区别?

答:Queue 属于一般队列,Deque 属于双端队列。一般队列是先进先出,也就是只有先进的才能先出;而双端队列则是两端都能插入和删除元素。

4.LinkedList 属于一般队列还是双端队列?

答:LinkedList 实现了 Deque 属于双端队列,因此拥有 addFirst(E)、addLast(E)、getFirst()、getLast()
等方法。

5.以下说法错误的是?

A:DelayQueue 内部是基于 PriorityQueue 实现的
B:PriorityBlockingQueue 不是先进先出的数据存储方式
C:LinkedBlockingQueue 默认容量是无限大的
D:ArrayBlockingQueue 内部的存储单元是数组,初始化时必须指定队列容量

答:C

题目解析:LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE,并不是无限大的。

6.关于 ArrayBlockingQueue 说法不正确的是?

A:ArrayBlockingQueue 是线程安全的
B:ArrayBlockingQueue 元素允许为 null
C:ArrayBlockingQueue 主要应用场景是“生产者-消费者”模型
D:ArrayBlockingQueue 必须显示地设置容量

答:B

题目解析:ArrayBlockingQueue 不允许元素为 null,如果添加一个 null 元素,会抛 NullPointerException 异常。

7.以下程序执行的结果是什么?

PriorityQueue priorityQueue = new PriorityQueue();
priorityQueue.add(null);
System.out.println(priorityQueue.size());

答:程序执行报错,PriorityQueue 不能插入 null。

8.Java 中常见的阻塞队列有哪些?

答:Java 中常见的阻塞队列如下:

  • ArrayBlockingQueue,由数组结构组成的有界阻塞队列;
  • PriorityBlockingQueue,支持优先级排序的无界阻塞队列;
  • SynchronousQueue,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素;
  • LinkedBlockingQueue,由链表结构组成的阻塞队列;
  • DelayQueue,支持延时获取元素的无界阻塞队列。

9.有界队列和无界队列有哪些区别?

答:有界队列和无界队列的区别如下。

  • 有界队列:有固定大小的队列叫做有界队列,比如:new ArrayBlockingQueue(6),6 就是队列的大小。
  • 无界队列:指的是没有设置固定大小的队列,这些队列的特点是可以直接入列,直到溢出。它们并不是真的无界,它们最大值通常为 Integer.MAX_VALUE,只是平常很少能用到这么大的容量(超过 Integer.MAX_VALUE),因此从使用者的体验上,就相当于 “无界”。

10.如何手动实现一个延迟消息队列?

答:说到延迟消息队列,我们应该可以第一时间想到要使用 DelayQueue
延迟队列来解决这个问题。实现思路,消息队列分为生产者和消费者,生产者用于增加消息,消费者用于获取并消费消息,我们只需要生产者把消息放入到
DelayQueue 队列并设置延迟时间,消费者循环使用 take() 阻塞获取消息即可。完整的实现代码如下:

public class CustomDelayQueue {
    // 消息编号
    static AtomicInteger MESSAGENO = new AtomicInteger(1);

    public static void main(String[] args) throws InterruptedException {
        DelayQueue delayQueue = new DelayQueue<>();
        // 生产者1
        producer(delayQueue, "生产者1");
        // 生产者2
        producer(delayQueue, "生产者2");
        // 消费者
        consumer(delayQueue);
    }

    //生产者
    private static void producer(DelayQueue delayQueue, String name) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    // 产生 1~5 秒的随机数
                    long time = 1000L * (new Random().nextInt(5) + 1);
                    try {
                        Thread.sleep(time);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 组合消息体
                    String message = String.format("%s,消息编号:%s 发送时间:%s 延迟:%s 秒",
                            name, MESSAGENO.getAndIncrement(), DateFormat.getDateTimeInstance().format(new Date()), time / 1000);
                    // 生产消息
                    delayQueue.put(new DelayedElement(message, time));
                }
            }
        }).start();
    }

    //消费者
    private static void consumer(DelayQueue delayQueue) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    DelayedElement element = null;
                    try {
                        // 消费消息
                        element = delayQueue.take();
                        System.out.println(element);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
    }

    // 延迟队列对象
    static class DelayedElement implements Delayed {
        // 过期时间(单位:毫秒)
        long time = System.currentTimeMillis();
        // 消息体
        String message;
        // 参数:delayTime 延迟时间(单位毫秒)
        public DelayedElement(String message, long delayTime) {
            this.time += delayTime;
            this.message = message;
        }
        @Override
        // 获取过期时间
        public long getDelay(TimeUnit unit) {
            return unit.convert(time - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }
        @Override
        // 队列元素排序
        public int compareTo(Delayed o) {
            if (this.getDelay(TimeUnit.MILLISECONDS) > o.getDelay(TimeUnit.MILLISECONDS))
                return 1;
            else if (this.getDelay(TimeUnit.MILLISECONDS) < o.getDelay(TimeUnit.MILLISECONDS))
                return -1;
            else
                return 0;
        }
        @Override
        public String toString() {
            // 打印消息
            return message + " |执行时间:" + DateFormat.getDateTimeInstance().format(new Date());
        }
    }
}

以上程序支持多生产者,执行的结果如下:

生产者1,消息编号:1 发送时间:2019-6-12 20:38:37 延迟:2 秒 |执行时间:2019-6-12 20:38:39

生产者2,消息编号:2 发送时间:2019-6-12 20:38:37 延迟:2 秒 |执行时间:2019-6-12 20:38:39

生产者1,消息编号:3 发送时间:2019-6-12 20:38:41 延迟:4 秒 |执行时间:2019-6-12 20:38:45

生产者1,消息编号:5 发送时间:2019-6-12 20:38:43 延迟:2 秒 |执行时间:2019-6-12 20:38:45

总结

队列(Queue)按照是否阻塞可分为:阻塞队列 BlockingQueue 和 非阻塞队列。其中,双端队列 Deque
也属于非阻塞队列,双端队列除了拥有队列的先进先出的方法之外,还拥有自己独有的方法,如
addFirst()、addLast()、getFirst()、getLast() 等,支持首未插入和删除元素。队列中比较常用的两个队列还有
PriorityQueue(优先级队列)和
DelayQueue(延迟队列),可使用延迟队列来实现延迟消息队列,这也是面试中比较常考的问题之一。需要面试朋友对延迟队列一定要做到心中有数,动手写一个消息队列也是非常有必要的。

java.io 包下的类有哪些 + 面试题

IO 介绍

IO 是 Input/Output 的缩写,它是基于流模型实现的,比如操作文件时使用输入流和输出流来写入和读取文件等。

IO 分类

传统的 IO,按照流类型我们可以分为:

  • 字符流
  • 字节流

其中,字符流包括 Reader、Writer;字节流包括 InputStream、OutputStream。
传统 IO 的类关系图,如下图所示:

JAVA面试汇总第二章 类和数据结构_第18张图片

IO 使用

了解了 IO
之间的关系,下面我们正式进入实战环节,分别来看字符流(Reader、Writer)和字节流(InputStream、OutputStream)的使用。

① Writer 使用

Writer 可用来写入文件,请参考以下代码:

// 给指定目录下的文件追加信息
Writer writer = new FileWriter("d:\\io.txt",true);
writer.append("老王");
writer.close();

这几行简单的代码就可以实现把信息 老王 追加到 d:\\io.txt 的文件下,参数二表示的是覆盖文字还是追加文字。

② Reader 使用

Reader 可用来读取文件,请参考以下代码:

Reader reader = new FileReader("d:\\io.txt");
BufferedReader bufferedReader = new BufferedReader(reader);
String str = null;
// 逐行读取信息
while (null != (str = bufferedReader.readLine())) {
    System.out.println(str);
}
bufferedReader.close();
reader.close();
③ InputStream 使用

InputStream 可用来读取文件,请参考以下代码:

InputStream inputStream = new FileInputStream(new File("d:\\io.txt"));
byte[] bytes = new byte[inputStream.available()];
// 读取到 byte 数组
inputStream.read(bytes);
// 内容转换为字符串
String content = new String(bytes, "UTF-8");
inputStream.close();
④ OutputStream 使用

OutputStream 可用来写入文件,请参考以下代码:

OutputStream outputStream = new FileOutputStream(new File("d:\\io.txt"),true);
outputStream.write("老王".getBytes());
outputStream.close();

NIO 介绍

上面讲的内容都是 java.io 包下的知识点,但随着 Java 的不断发展,在 Java 1.4 时新的 IO 包出现了
java.nio,NIO(Non-Blocking IO)的出现解决了传统 IO,也就是我们经常说的 BIO(Blocking IO)同步阻塞的问题,NIO
提供了 Channel、Selector 和 Buffer 等概念,可以实现多路复用和同步非阻塞 IO 操作,从而大大提升了 IO 操作的性能。
前面提到同步和阻塞的问题,那下面来看看同步和阻塞结合都有哪些含义。

组合方式 性能分析
同步阻塞 最常用的一种用法,使用也是最简单的,但是 I/O 性能一般很差,CPU 大部分在空闲状态
同步非阻塞 提升 I/O 性能的常用手段,就是将 I/O 的阻塞改成非阻塞方式,尤其在网络 I/O
是长连接,同时传输数据也不是很多的情况下,提升性能非常有效。 这种方式通常能提升 I/O 性能,但是会增加 CPU 消耗,要考虑增加的 I/O
性能能不能补偿 CPU 的消耗,也就是系统的瓶颈是在 I/O 还是在 CPU 上
异步阻塞
这种方式在分布式数据库中经常用到。例如,在往一个分布式数据库中写一条记录,通常会有一份是同步阻塞的记录,而还有两至三份是备份记录会写到其他机器上,这些备份记录通常都是采用异步阻塞的方式写
I/O;异步阻塞对网络 I/O 能够提升效率,尤其像上面这种同时写多份相同数据的情况
异步非阻塞 这种组合方式用起来比较复杂,只有在一些非常复杂的分布式情况下使用,像集群之间的消息同步机制一般用这种 I/O
组合方式。例如,Cassandra 的 Gossip
通信机制就是采用异步非阻塞的方式。它适合同时要传多份相同的数据到集群中不同的机器,同时数据的传输量虽然不大,但是却非常频繁。这种网络 I/O
用这个方式性能能达到最高

了解了同步和阻塞的含义,下面来看 NIO 的具体使用 ,请参考以下代码:

int port = 6666;
new Thread(new Runnable() {
    @Override
    public void run() {
        try (Selector selector = Selector.open();
             ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();) {
            serverSocketChannel.bind(new InetSocketAddress(InetAddress.getLocalHost(), port));
            serverSocketChannel.configureBlocking(false);
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                selector.select(); // 阻塞等待就绪的 Channel
                Set selectionKeys = selector.selectedKeys();
                Iterator iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    try (SocketChannel channel = ((ServerSocketChannel) key.channel()).accept()) {
                        channel.write(Charset.defaultCharset().encode("老王,你好~"));
                    }
                    iterator.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}).start();

new Thread(new Runnable() {
    @Override
    public void run() {
        // Socket 客户端 1(接收信息并打印)
        try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream()));
            bufferedReader.lines().forEach(s -> System.out.println("客户端 1 打印:" + s));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}).start();

new Thread(new Runnable() {
    @Override
    public void run() {
        // Socket 客户端 2(接收信息并打印)
        try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) {
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream()));
            bufferedReader.lines().forEach(s -> System.out.println("客户端 2 打印:" + s));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}).start();

以上代码创建了两个 Socket 客户端,用于收取和打印服务器端的消息。
其中,服务器端通过 SelectionKey(选择键)获取到 SocketChannel(通道),而通道都注册到
Selector(选择器)上,所有的客户端都可以获得对应的通道,而不是所有客户端都排队堵塞等待一个服务器连接,这样就实现多路复用的效果了。多路指的是多个通道(SocketChannel),而复用指的是一个服务器端连接重复被不同的客户端使用。

AIO 介绍

AIO(Asynchronous IO)是 NIO 的升级,也叫 NIO2,实现了异步非堵塞 IO ,异步 IO 的操作基于事件和回调机制。
AIO 实现简单的 Socket 服务器,代码如下:

int port = 8888;
new Thread(new Runnable() {
    @Override
    public void run() {
        AsynchronousChannelGroup group = null;
        try {
            group = AsynchronousChannelGroup.withThreadPool(Executors.newFixedThreadPool(4));
            AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open(group).bind(new InetSocketAddress(InetAddress.getLocalHost(), port));
            server.accept(null, new CompletionHandler() {
                @Override
                public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) {
                    server.accept(null, this); // 接收下一个请求
                    try {
                        Future f = result.write(Charset.defaultCharset().encode("Hi, 老王"));
                        f.get();
                        System.out.println("服务端发送时间:" + DateFormat.getDateTimeInstance().format(new Date()));
                        result.close();
                    } catch (InterruptedException | ExecutionException | IOException e) {
                        e.printStackTrace();
                    }
                }
                @Override
                public void failed(Throwable exc, AsynchronousServerSocketChannel attachment) {
                }
            });
            group.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}).start();

// Socket 客户端
AsynchronousSocketChannel client = AsynchronousSocketChannel.open();
Future future = client.connect(new InetSocketAddress(InetAddress.getLocalHost(), port));
future.get();
ByteBuffer buffer = ByteBuffer.allocate(100);
client.read(buffer, null, new CompletionHandler() {
    @Override
    public void completed(Integer result, Void attachment) {
        System.out.println("客户端打印:" + new String(buffer.array()));
    }

    @Override
    public void failed(Throwable exc, Void attachment) {
        exc.printStackTrace();
        try {
            client.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
});
Thread.sleep(10 * 1000);

相关面试题

1.使用以下哪个方法来判断一个文件是否存在?

A:createFile
B:exists
C:read
D:exist

答:B

2.以下说法错误的是?

A:同步操作不一定会阻塞
B:异步操作不一定会阻塞
C:阻塞一定是同步操作
D:同步或异步都可能会阻塞

答:C

题目解析:异步操作也可能会阻塞,比如分布式集群消息同步,采用的就是异步阻塞的方式。

3.BIO、NIO、AIO 的区别是什么?

答:它们三者的区别如下。

  • BIO 就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。它的优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。
  • NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层高性能的数据操作方式。
  • AIO 是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,因此人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

简单来说 BIO 就是传统 IO 包,产生的最早;NIO 是对 BIO 的改进提供了多路复用的同步非阻塞 IO,而 AIO 是 NIO
的升级,提供了异步非阻塞 IO。

4.读取和写入文件最简洁的方式是什么?

答:使用 Java 7 提供的 Files 读取和写入文件是最简洁,请参考以下代码:

// 读取文件
byte[] bytes = Files.readAllBytes(Paths.get("d:\\io.txt"));
// 写入文件
Files.write(Paths.get("d:\\io.txt"), "追加内容".getBytes(), StandardOpenOption.APPEND);

读取和写入都是一行代码搞定,可以说很简洁了。

5.Files 常用方法都有哪些?

答:Files 是 Java 1.7 提供的,使得文件和文件夹的操作更加方便,它的常用方法有以下几个:

  • Files. exists():检测文件路径是否存在
  • Files. createFile():创建文件
  • Files. createDirectory():创建文件夹
  • Files. delete():删除一个文件或目录
  • Files. copy():复制文件
  • Files. move():移动文件
  • Files. size():查看文件个数
  • Files. read():读取文件
  • Files. write():写入文件

6.FileInputStream 可以实现什么功能?

答:FileInputStream 可以实现文件的读取。

题目解析:因为 FileInputStream 和 FileOutputStream 很容易被记反,FileOutputStream
才是用来写入文件的,所以也经常被面试官问到。

7.不定项选择:为了提高读写性能,可以采用什么流?

A:InputStream
B:DataInputStream
C:BufferedReader
D:BufferedInputStream
E:OutputStream
F:BufferedOutputStream

答:D、F

题目解析:BufferedInputStream
是一种带缓存区的输入流,在读取字节数据时可以从底层流中一次性读取多个字节到缓存区,而不必每次都调用系统底层;同理,BufferedOutputStream
也是一种带缓冲区的输出流,通过缓冲区输出流,应用程序先把字节写入缓冲区,缓存区满后再调用操作系统底层,从而提高系统性能,而不必每次都去调用系统底层方法。

8.FileInputStream 和 BufferedInputStream 的区别是什么?

答:FileInputStream 在小文件读写时性能较好,而在大文件操作时使用 BufferedInputStream 更有优势。

9.以下这段代码运行在 Windwos 平台,执行的结果是?

Files.createFile(Paths.get("c:\\pf.txt"), PosixFilePermissions.asFileAttribute(
    EnumSet.of(PosixFilePermission.OWNER_READ)));

A:在指定的盘符产生了对应的文件,文件只读
B:在指定的盘符产生了对应的文件,文件只写
C:在指定的盘符产生了对应的文件,文件可读写
D:程序报错

答:D

题目解析:本题目考察的是 Files.createFile 参数传递的问题,PosixFilePermissions 不支持 Windows,因此在
Windows 执行会报错 java.lang.UnsupportedOperationException: ‘posix:permissions’ not
supported as initial attribute。

总结

在 Java 1.4 之前只有 BIO(Blocking IO)可供使用,也就是 java.io 包下的那些类,它的缺点是同步阻塞式运行的。随后在 Java
1.4 时,提供了 NIO(Non-Blocking IO)属于 BIO 的升级,提供了同步非阻塞的 IO 操作方式,它的重要组件是
Selector(选择器)、Channel(通道)、Buffer(高效数据容器)实现了多路复用的高效 IO 操作。而 AIO(Asynchronous
IO)也叫 NIO 2.0,属于 NIO 的补充和升级,提供了异步非阻塞的 IO 操作。

还有另一个重要的知识点,是 Java 7.0 时新增的 Files 类,极大地提升了文件操作的便利性,比如读、写文件

题目解析:异步操作也可能会阻塞,比如分布式集群消息同步,采用的就是异步阻塞的方式。

3.BIO、NIO、AIO 的区别是什么?

答:它们三者的区别如下。

  • BIO 就是传统的 java.io 包,它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。它的优点就是代码比较简单、直观;缺点就是 IO 的效率和扩展性很低,容易成为应用性能瓶颈。
  • NIO 是 Java 1.4 引入的 java.nio 包,提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层高性能的数据操作方式。
  • AIO 是 Java 1.7 之后引入的包,是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,因此人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

简单来说 BIO 就是传统 IO 包,产生的最早;NIO 是对 BIO 的改进提供了多路复用的同步非阻塞 IO,而 AIO 是 NIO
的升级,提供了异步非阻塞 IO。

4.读取和写入文件最简洁的方式是什么?

答:使用 Java 7 提供的 Files 读取和写入文件是最简洁,请参考以下代码:

// 读取文件
byte[] bytes = Files.readAllBytes(Paths.get("d:\\io.txt"));
// 写入文件
Files.write(Paths.get("d:\\io.txt"), "追加内容".getBytes(), StandardOpenOption.APPEND);

读取和写入都是一行代码搞定,可以说很简洁了。

5.Files 常用方法都有哪些?

答:Files 是 Java 1.7 提供的,使得文件和文件夹的操作更加方便,它的常用方法有以下几个:

  • Files. exists():检测文件路径是否存在
  • Files. createFile():创建文件
  • Files. createDirectory():创建文件夹
  • Files. delete():删除一个文件或目录
  • Files. copy():复制文件
  • Files. move():移动文件
  • Files. size():查看文件个数
  • Files. read():读取文件
  • Files. write():写入文件

6.FileInputStream 可以实现什么功能?

答:FileInputStream 可以实现文件的读取。

题目解析:因为 FileInputStream 和 FileOutputStream 很容易被记反,FileOutputStream
才是用来写入文件的,所以也经常被面试官问到。

7.不定项选择:为了提高读写性能,可以采用什么流?

A:InputStream
B:DataInputStream
C:BufferedReader
D:BufferedInputStream
E:OutputStream
F:BufferedOutputStream

答:D、F

题目解析:BufferedInputStream
是一种带缓存区的输入流,在读取字节数据时可以从底层流中一次性读取多个字节到缓存区,而不必每次都调用系统底层;同理,BufferedOutputStream
也是一种带缓冲区的输出流,通过缓冲区输出流,应用程序先把字节写入缓冲区,缓存区满后再调用操作系统底层,从而提高系统性能,而不必每次都去调用系统底层方法。

8.FileInputStream 和 BufferedInputStream 的区别是什么?

答:FileInputStream 在小文件读写时性能较好,而在大文件操作时使用 BufferedInputStream 更有优势。

9.以下这段代码运行在 Windwos 平台,执行的结果是?

Files.createFile(Paths.get("c:\\pf.txt"), PosixFilePermissions.asFileAttribute(
    EnumSet.of(PosixFilePermission.OWNER_READ)));

A:在指定的盘符产生了对应的文件,文件只读
B:在指定的盘符产生了对应的文件,文件只写
C:在指定的盘符产生了对应的文件,文件可读写
D:程序报错

答:D

题目解析:本题目考察的是 Files.createFile 参数传递的问题,PosixFilePermissions 不支持 Windows,因此在
Windows 执行会报错 java.lang.UnsupportedOperationException: ‘posix:permissions’ not
supported as initial attribute。

总结

在 Java 1.4 之前只有 BIO(Blocking IO)可供使用,也就是 java.io 包下的那些类,它的缺点是同步阻塞式运行的。随后在 Java
1.4 时,提供了 NIO(Non-Blocking IO)属于 BIO 的升级,提供了同步非阻塞的 IO 操作方式,它的重要组件是
Selector(选择器)、Channel(通道)、Buffer(高效数据容器)实现了多路复用的高效 IO 操作。而 AIO(Asynchronous
IO)也叫 NIO 2.0,属于 NIO 的补充和升级,提供了异步非阻塞的 IO 操作。

还有另一个重要的知识点,是 Java 7.0 时新增的 Files 类,极大地提升了文件操作的便利性,比如读、写文件
Files.write()、Files.readAllBytes() 等,都是非常简便和实用的方法。

你可能感兴趣的:(java,教程,java,开发语言,后端,面试)