巨详细的Java面试问题及复习内容(Java基础--第二部分)

面经这种东西,别人写的永远比不上自己敲的。


这一篇延续上一篇的Java基础,继续来整理一些基础问题。


                   二、Java基础(第二部分)

2.1、字符型常量和字符串常量的区别

2.2、关于注释

2.3、标识符和关键字的区别?

2.4、为什么Java中只有值传递?

2.5、一个类的构造方法的作用是什么?若一个类没有声明构造方法,该程序能正确执行吗?为什么?

2.6、序列化与反序列化

2.7、什么是内部类?Static Nested Class和Inner Class的不同。

2.8、java 容器都有哪些?

2.9、容器中的设计模式

2.10、Collection 和 Collections 有什么区别?

2.11、ArrayList

2.11、Vector

2.12、LinkedList

2.13、HashMap

2.14、ConcurrentHashMap

2.15、LinkedHashMap

2.16、WeakHashMap

2.17、关于List

2.18、关于Set

2.19、关于Map

2.20、其他集合问题


二、Java基础(第二部分)

2.1、字符型常量和字符串常量的区别

  • 形式上: 字符常量是单引号引起的一个字符; 字符串常量是双引号引起的0个或若干个字符;
  • 含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)
  • 占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节 (注意: char 在 Java 中占两个字节)

字符封装类 Character 有一个成员常量 Character.SIZE 值为16,单位是bits,该值除以8(1byte=8bits)后就可以得到2个字节

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第1张图片 java 编程思想第四版:2.2.2 节
附:Java中8种基本数据类型
基本类型 位数 字节 默认值
int 32 4 0
short 16 2 0
long 64 8 0L
byte 8 1 0
char 16 2 'u0000'
float 32 4 0f
double 64 8 0d
boolean 1   false

Java中有8种基本数据类型,分别为:

  • 6种数字类型 :byte、short、int、long、float、double
  • 1种字符类型:char
  • 1种布尔型:boolean

这八种基本类型都有对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean

对于boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1位,但是实际中会考虑计算机高效存储因素。

注意:

  1. Java 里使用 long 类型的数据一定要在数值后面加上 L,否则将作为整型解析:
  2. char a = 'h'char :单引号,String a = "hello" :双引号

 2.2、关于注释

Java 中的注释有三种:

//单行注释

/*
 *多行注释
 */

/**
 *文档注释。
 */

代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。

若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。

举个例子:
去掉下面复杂的注释,只需要创建一个与注释所言同一事物的函数即可

// check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))

应替换为

if (employee.isEligibleForFullBenefits())
                                                                    ————《Clean Code》

2.3、标识符和关键字的区别?

在我们编写程序的时候,需要大量地为程序、类、变量、方法等取名字,于是就有了标识符,简单来说,标识符就是一个名字。但是有一些标识符,Java 语言已经赋予了其特殊的含义,只能用于特定的地方,这种特殊的标识符就是关键字。因此,关键字是被赋予特殊含义的标识符。比如,在我们的日常生活中 ,“警察局”这个名字已经被赋予了特殊的含义,所以如果你开一家店,店的名字不能叫“警察局”,“警察局”就是我们日常生活中的关键字。

java中常见的关键字
访问控制 private protected public        
类,方法和变量修饰符 abstract class extends final implements interface native
  new static strictfp synchronized transient volatile  
程序控制 break continue return do while if else
  for instanceof switch case default    
错误处理 try catch throw throws finally    
包相关 import package          
基本类型 boolean byte char double float int long
  short null true false      
变量引用 super this void        
保留字 goto const        

2.4、为什么Java中只有值传递?

首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。按值调用(call by value)表示方法接收的是调用者提供的值,而按引用调用(call by reference)表示方法接收的是调用者提供的变量地址。一个方法可以修改传递引用所对应的变量值,而不能修改传递值调用所对应的变量值。 它用来描述各种程序设计语言(不只是 Java)中方法参数传递方式。

Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,也就是说,方法不能修改传递给它的任何参数变量的内容。

public static void main(String[] args) {
    int num1 = 10;
    int num2 = 20;

    swap(num1, num2);

    System.out.println("num1 = " + num1);
    System.out.println("num2 = " + num2);
}

public static void swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;

    System.out.println("a = " + a);
    System.out.println("b = " + b);
}
/***************************************/
a = 20
b = 10
num1 = 10
num2 = 20
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第2张图片 解析

在 swap 方法中,a、b 的值进行交换,并不会影响到 num1、num2。因为,a、b 中的值,只是从 num1、num2 的复制过来的。也就是说,a、b 相当于 num1、num2 的副本,副本的内容无论怎么修改,都不会影响到原件本身。

一个方法不能修改一个基本数据类型的参数,而对象引用作为参数就不一样:

    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        System.out.println(arr[0]);
        change(arr);
        System.out.println(arr[0]);
    }

    public static void change(int[] array) {
        // 将数组的第一个元素变为0
        array[0] = 0;
    }
/****************************************/
1
0
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第3张图片 解析

array 被初始化 arr 的拷贝也就是一个对象的引用,也就是说 array 和 arr 指向的是同一个数组对象。 因此,外部对引用对象的改变会反映到所对应的对象上。

我们已经看到,实现一个改变对象参数状态的方法并不是一件难事。理由很简单,方法得到的是对象引用的拷贝,对象引用及其他的拷贝同时引用同一个对象。

很多程序设计语言(特别是,C++和 Pascal)提供了两种参数传递的方式:值调用和引用调用。有些程序员认为 Java 程序设计语言对对象采用的是引用调用,实际上,这种理解是不对的。由于这种误解具有一定的普遍性,所以下面给出一个反例:

public class Test {

    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Student s1 = new Student("小张");
        Student s2 = new Student("小李");
        Test.swap(s1, s2);
        System.out.println("s1:" + s1.getName());
        System.out.println("s2:" + s2.getName());
    }

    public static void swap(Student x, Student y) {
        Student temp = x;
        x = y;
        y = temp;
        System.out.println("x:" + x.getName());
        System.out.println("y:" + y.getName());
    }
}
/********************************************/
x:小李
y:小张
s1:小张
s2:小李
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第4张图片 交换之前
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第5张图片 交换之后

通过上面两张图可以很清晰的看出: 方法并没有改变存储在变量 s1 和 s2 中的对象引用。swap 方法的参数 x 和 y 被初始化为两个对象引用的拷贝,这个方法交换的是这两个拷贝。

Java 程序设计语言对对象采用的不是引用调用,实际上,对象引用是按值传递的

下面再总结一下 Java 中方法参数的使用情况:

  • 一个方法不能修改一个基本数据类型的参数(即数值型或布尔型)。
  • 一个方法可以改变一个对象参数的状态。
  • 一个方法不能让对象参数引用一个新的对象。

                                                                                                  ——《Java 核心技术卷 Ⅰ》基础知识第十版第四章 4.5 小节

2.5、一个类的构造方法的作用是什么?若一个类没有声明构造方法,该程序能正确执行吗?为什么?

主要作用是完成对类对象的初始化工作。可以执行。因为一个类即使没有声明构造方法也会有默认的不带参数的构造方法。

如果我们自己添加了类的构造方法(无论是否有参),Java 就不会再添加默认的无参数的构造方法了,这时候,就不能直接 new 一个对象而不传递参数了,所以我们一直在不知不觉地使用构造方法,这也是为什么我们在创建对象的时候后面要加一个括号(因为要调用无参的构造方法)。如果我们重载了有参的构造方法,记得都要把无参的构造方法也写出来(无论是否用到),因为这可以帮助我们在创建对象的时候少踩坑。

在java中定义一个不做事且没有参数的构造方法的作用

Java 程序在执行子类的构造方法之前,如果没有用 super()来调用父类特定的构造方法,则会调用父类中“没有参数的构造方法”。因此,如果父类中只定义了有参数的构造方法,而在子类的构造方法中又没有用 super()来调用父类中特定的构造方法,则编译时将发生错误,因为 Java 程序在父类中找不到没有参数的构造方法可供执行。解决办法是在父类里加上一个不做事且没有参数的构造方法。

构造方法有哪些特性

  1. 名字与类名相同。
  2. 没有返回值,但不能用 void 声明构造函数。
  3. 生成类的对象时自动执行,无需调用。

在调用子类构造方法之前会先调用父类没有参数的构造方法其目的是:帮助子类做初始化工作。

2.6、序列化与反序列化

我们都知道一个对象只要实现了Serilizable接口,这个对象就可以被序列化,java的这种序列化模式为开发者提供了很多便利,我们可以不必关系具体序列化的过程,只要这个类实现了Serilizable接口,这个类的所有属性和方法都会自动序列化。

然而在实际开发过程中,我们常常会遇到这样的问题,这个类的有些属性需要序列化,而其他属性不需要被序列化,打个比方,如果一个用户有一些敏感信息(如密码,银行卡号等),为了安全起见,不希望在网络操作(主要涉及到序列化操作,本地序列化缓存也适用)中被传输,这些信息对应的变量就可以加上transient关键字。换句话说,这个字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。

transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。transient 只能修饰变量,不能修饰类和方法。

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
 
/**
 * @description 使用transient关键字不序列化某个变量
 *        注意读取的时候,读取数据的顺序一定要和存放数据的顺序保持一致
 */
public class TransientTest {
    
    public static void main(String[] args) {
        
        User user = new User();
        user.setUsername("Alexia");
        user.setPasswd("123456");
        
        System.out.println("read before Serializable: ");
        System.out.println("username: " + user.getUsername());
        System.err.println("password: " + user.getPasswd());
        
        try {
            ObjectOutputStream os = new ObjectOutputStream(
                    new FileOutputStream("C:/user.txt"));
            os.writeObject(user); // 将User对象写进文件
            os.flush();
            os.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            ObjectInputStream is = new ObjectInputStream(new FileInputStream(
                    "C:/user.txt"));
            user = (User) is.readObject(); // 从流中读取User的数据
            is.close();
            
            System.out.println("\nread after Serializable: ");
            System.out.println("username: " + user.getUsername());
            System.err.println("password: " + user.getPasswd());
            
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
 
class User implements Serializable {
    private static final long serialVersionUID = 8294180014912103005L;  
    
    private String username;
    private transient String passwd;
    
    public String getUsername() {
        return username;
    }
    
    public void setUsername(String username) {
        this.username = username;
    }
    
    public String getPasswd() {
        return passwd;
    }
    
    public void setPasswd(String passwd) {
        this.passwd = passwd;
    }
 
}
/*****************************************/

read before Serializable: 
username: Alexia
password: 123456
 
read after Serializable: 
username: Alexia

总结

  • 一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。
  • transient关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient关键字修饰的。变量如果是用户自定义类变量,则该类需要实现Serializable接口。
  • 被transient关键字修饰的变量不再能被序列化,一个静态变量不管是否被transient修饰,均不能被序列化。

关于总结的最后一点:被transient关键字修饰的变量不再能被序列化,一个静态变量不管是否被transient修饰,均不能被序列化。

可能有些人很迷惑,因为发现在User类中的username字段前加上static关键字后,程序运行结果依然不变,即static类型的username也读出来为“Alexia”了,这不与第三点说的矛盾吗?实际上是这样的:第三点确实没错(一个静态变量不管是否被transient修饰,均不能被序列化),反序列化后类中static型变量username的值为当前JVM中对应static变量的值,这个值是JVM中的不是反序列化得出的:

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
 
/**
 * @description 使用transient关键字不序列化某个变量
 *        注意读取的时候,读取数据的顺序一定要和存放数据的顺序保持一致
 */
public class TransientTest {
    
    public static void main(String[] args) {
        
        User user = new User();
        user.setUsername("Alexia");
        user.setPasswd("123456");
        
        System.out.println("read before Serializable: ");
        System.out.println("username: " + user.getUsername());
        System.err.println("password: " + user.getPasswd());
        
        try {
            ObjectOutputStream os = new ObjectOutputStream(
                    new FileOutputStream("C:/user.txt"));
            os.writeObject(user); // 将User对象写进文件
            os.flush();
            os.close();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        try {
            // 在反序列化之前改变username的值
            User.username = "jmwang";
            
            ObjectInputStream is = new ObjectInputStream(new FileInputStream(
                    "C:/user.txt"));
            user = (User) is.readObject(); // 从流中读取User的数据
            is.close();
            
            System.out.println("\nread after Serializable: ");
            System.out.println("username: " + user.getUsername());
            System.err.println("password: " + user.getPasswd());
            
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}
 
class User implements Serializable {
    private static final long serialVersionUID = 8294180014912103005L;  
    
    public static String username;
    private transient String passwd;
    
    public String getUsername() {
        return username;
    }
    
    public void setUsername(String username) {
        this.username = username;
    }
    
    public String getPasswd() {
        return passwd;
    }
    
    public void setPasswd(String passwd) {
        this.passwd = passwd;
    }
 
}
/*******************************************/
read before Serializable: 
username: Alexia
password: 123456
 
read after Serializable: 
username: jmwang
password: null

这说明反序列化后类中static型变量username的值为当前JVM中对应static变量的值,为修改后jmwang,而不是序列化时的值Alexia。

transient使用细节——被transient关键字修饰的变量真的不能被序列化吗?

import java.io.Externalizable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectInputStream;
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;
 
/**
 * @descripiton Externalizable接口的使用
 */
public class ExternalizableTest implements Externalizable {
 
    private transient String content = "是的,我将会被序列化,不管我是否被transient关键字修饰";
 
    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeObject(content);
    }
 
    @Override
    public void readExternal(ObjectInput in) throws IOException,
            ClassNotFoundException {
        content = (String) in.readObject();
    }
 
    public static void main(String[] args) throws Exception {
        
        ExternalizableTest et = new ExternalizableTest();
        ObjectOutput out = new ObjectOutputStream(new FileOutputStream(
                new File("test")));
        out.writeObject(et);
 
        ObjectInput in = new ObjectInputStream(new FileInputStream(new File(
                "test")));
        et = (ExternalizableTest) in.readObject();
        System.out.println(et.content);
 
        out.close();
        in.close();
    }
}

content变量会被序列化吗?答案都输出来了是的,运行结果就是:

是的,我将会被序列化,不管我是否被transient关键字修饰

为什么呢,不是说类的变量被transient关键字修饰以后将不能序列化了吗?

      我们知道在Java中,对象的序列化可以通过实现两种接口来实现,若实现的是Serializable接口,则所有的序列化将会自动进行,若实现的是Externalizable接口,则没有任何东西可以自动序列化,需要在writeExternal方法中进行手工指定所要序列化的变量,这与是否被transient修饰无关。因此输出的是变量content初始化的内容,而不是null。

                                                                               ——以上内容转自https://blog.csdn.net/lisulong1/article/details/52269262

2.7、什么是内部类?Static Nested ClassInner Class的不同。

内部类就是在一个类的内部定义的类,内部类中不能定义静态成员。

静态成员不是对象的特性,只是为了找一个容身之处,所以需要放到一个类中而已,这么一点小事,还要把它放到类内部的一个类中,过分了啊!提供内部类,不是为干这种事情,无聊。可能是既然静态成员类似c语言的全局变量,而内部类通常是用于创建内部对象用的,所以,把“全局变量”放在内部类中就是毫无意义的事情,既然是毫无意义的事情,就应该被禁止。

内部类可以直接访问外部类中的成员变量,内部类可以定义在外部类的方法外面,也可以定义在外部类的方法体中,如下所示:

public class Outer{
        int out_x  = 0;
        public void method(){

               Inner1 inner1 = new Inner1();
               public class Inner2  //在方法体内部定义的内部类
               {
                      public method(){
                             out_x = 3;
                      }
               }
               Inner2 inner2 = new Inner2();
        }

        public class Inner1  //在方法体外面定义的内部类
        {
        }
}

在方法体外面定义的内部类的访问类型可以是public,protecte,默认的,private等4种类型,这就好像类中定义的成员变量有4种访问类型一样,它们决定这个内部类的定义对其他类是否可见;对于这种情况,我们也可以在外面创建内部类的实例对象,创建内部类的实例对象时,一定要先创建外部类的实例对象,然后用这个外部类的实例对象去创建内部类的实例对象,代码如下:

Outer outer = new Outer();

Outer.Inner1 inner1 = outer.new Innner1();

在方法内部定义的内部类前面不能有访问类型修饰符,就好像方法中定义的局部变量一样,但这种内部类的前面可以使用final或abstract修饰符。这种内部类对其他类是不可见的其他类无法引用这种内部类,但是这种内部类创建的实例对象可以传递给其他类访问。这种内部类必须是先定义,后使用,即内部类的定义代码必须出现在使用该类之前,这与方法中的局部变量必须先定义后使用的道理也是一样的。这种内部类可以访问方法体中的局部变量,但是,该局部变量前必须加final修饰符。

在方法体内部还可以采用如下语法来创建一种匿名内部类,即定义某一接口或类的子类的同时,还创建了该子类的实例对象,无需为该子类定义名称:

public class Outer{
        public void start(){
               new Thread(
                    new Runable(){
                             public void run(){};
                    }
               ).start();
        }
}

最后,在方法外部定义的内部类前面可以加上static关键字,从而成为Static Nested Class,它不再具有内部类的特性,所有,从狭义上讲,它不是内部类。Static Nested Class与普通类在运行时的行为和功能上没有什么区别,只是在编程引用时的语法上有一些差别,它可以定义成public、protected、默认的、private等多种类型,而普通类只能定义成public和默认的这两种类型。在外面引用Static Nested Class类的名称为“外部类名.内部类名”。在外面不需要创建外部类的实例对象,就可以直接创建Static Nested Class,例如,假设Inner是定义在Outer类中的Static Nested Class,那么可以使用如下语句创建Inner类:

Outer.Inner inner = newOuter.Inner();

由于static Nested Class不依赖于外部类的实例对象,所以,static Nested Class能访问外部类的非static成员变量(不能直接访问,需要创建外部类实例才能访问非静态变量)当在外部类中访问Static Nested Class时,可以直接使用Static Nested Class的名字,而不需要加上外部类的名字了,在Static Nested Class中也可以直接引用外部类的static的成员变量,不需要加上外部类的名字。

在静态方法中定义的内部类也是Static Nested Class,这时候不能在类前面加static关键字,静态方法中的Static Nested Class与普通方法中的内部类的应用方式很相似,它除了可以直接访问外部类中的static的成员变量,还可以访问静态方法中的局部变量,但是,该局部变量前必须加final修饰符。

内部类可以引用它的包含类的成员吗?有没有什么限制?

完全可以。如果不是静态内部类,那没有什么限制!

而静态内部类,例如下面的代码:

class Outer{
    static int x;
    static class Inner{
        void test(){
               syso(x);
        }
    }
}

如果你把静态嵌套类当作内部类的一种特例,那在这种情况下不可以访问外部类的普通成员变量,而只能访问外部类中的静态成员。

2.8、java 容器都有哪些?

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第6张图片 https://snailclimb.gitee.io
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第7张图片 https://cyc2018.github.io/
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第8张图片 https://cyc2018.github.io/

Set

  • TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。

  • HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。

  • LinkedHashSet:具有 HashSet 的查找效率,并且内部使用双向链表维护元素的插入顺序。

List

  • ArrayList:基于动态数组实现,支持随机访问。

  • Vector:和 ArrayList 类似,但它是线程安全的。

  • LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。

Queue

  • LinkedList:可以用它来实现双向队列。

  • PriorityQueue:基于堆结构实现,可以用它来实现优先队列。

Map

  • TreeMap:基于红黑树实现。

  • HashMap:基于哈希表实现。

  • HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 HashTable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全,ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。

  • LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

2.9、容器中的设计模式

迭代器模式

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第9张图片 迭代器模式

Collection 继承了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素。从 JDK 1.5 之后可以使用 foreach 方法来遍历实现了 Iterable 接口的聚合对象。

List list = new ArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
    System.out.println(item);
}
什么是迭代器?

public interface Iterator {
    //集合中是否还有元素
    boolean hasNext();
    //获得集合中的下一个元素
    E next();
    ......
}

Iterator 对象称为迭代器,迭代器可以对集合进行遍历,但每一个集合内部的数据结构可能是不尽相同的,所以每一个集合存和取都很可能是不一样的,虽然我们可以人为地在每一个类中定义 hasNext() 和 next() 方法,但这样做会让整个集合体系过于臃肿。于是就有了迭代器。

迭代器是将这样的方法抽取出接口,然后在每个类的内部,定义自己迭代方式,这样做就规定了整个集合体系的遍历方式都是 hasNext()next()方法,使用者不用管怎么实现的,会用即可。迭代器的定义为:提供一种方法访问一个容器对象中各个元素,而又不需要暴露该对象的内部细节。

迭代器Iterator有啥用?

Iterator 主要是用来遍历集合用的,它的特点是更加安全,因为它可以确保,在当前遍历的集合元素被更改的时候,就会抛出 ConcurrentModificationException 异常。

如何正确使用?

Map map = new HashMap();
map.put(1, "Java");
map.put(2, "C++");
map.put(3, "PHP");
Iterator> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
  Map.Entry entry = iterator.next();
  System.out.println(entry.getKey() + entry.getValue());
}

我们通过使用迭代器来遍历 HashMap,演示一下迭代器 Iterator 的使用。

适配器模式

java.util.Arrays#asList() 可以把数组类型转换为 List 类型。

@SafeVarargs
public static  List asList(T... a)

应该注意的是 asList() 的参数为泛型的变长参数,不能使用基本类型数组作为参数,只能使用相应的包装类型数组。

Integer[] arr = {1, 2, 3};
List list = Arrays.asList(arr);

也可以使用以下方式调用 asList():

List list = Arrays.asList(1, 2, 3);

适配器模式是23中设计模式之一,它的主要作用是在新接口和老接口之间进行适配。它非常像我们出国旅行时带的电源转换器 ,我们国家的电器使用普通的扁平两项或三项插头,而去外国的话,使用的标准就不一样了,比如德国,使用的是德国标准,是两项圆头的插头。如果去德国旅游,那么我们使用的手机充电器插头无法插到德国的插排中去,那就意味着我们无法给手机充电。怎样解决这个问题呢?只要使用一个电源转化器就行了。

下面我们使用代码来表述适配器模式:

代码中有两个接口,分别为德标接口和国标接口,分别命名为DBSocketInterface和GBSocketInterface,此外还有两个实现类,分别为德国插座和中国插座,分别为DBSocket和GBSocket。为了提供两套接口之间的适配,我们提供了一个适配器,叫做SocketAdapter。除此之外,还有一个客户端,比如是我们去德国旅游时住的一家宾馆,叫Hotel,在这个德国旅馆中使用德国接口。

/** 
 * 德标接口 
 */  
public interface DBSocketInterface {  

    /** 
     * 这个方法的名字叫做:使用两项圆头的插口供电 
     */  
    void powerWithTwoRound();  
}  

/** 
 * 德国插座 
 */  
public class DBSocket implements DBSocketInterface{  

    public void powerWithTwoRound(){  
        System.out.println("使用两项圆头的插孔供电");  
    }  
}  

/** 
 * 德国宾馆 
 */  
public class Hotel {  

    //旅馆中有一个德标的插口  
    private DBSocketInterface dbSocket;  

    public Hotel(){}  

    public Hotel(DBSocketInterface dbSocket) {  
        this.dbSocket = dbSocket;  
    }  

    //提供的两种注入方式
    public void setSocket (DBSocketInterface dbSocket){  
        this.dbSocket = dbSocket;  
    }  

    //旅馆中有一个充电的功能  
    public void charge(){  

        //使用德标插口充电  
        dbSocket.powerWithTwoRound();  
    }  
}  

现在写一个测试类来实现德国宾馆中有德国插座:

public class Test {  

    public static void main(String[] args) {  

        //初始化一个德国插座对象, 用一个德标接口引用它  
        DBSocketInterface dbSoket = new DBSocket();  

        //创建一个旅馆对象,并且宾馆里面有德国接口的插座  
        Hotel hotel = new Hotel(dbSoket);  

        //在旅馆中给手机充电  
        hotel.charge();  
    }  
}  

现在我去德国旅游,带去的三项扁头的手机充电器。如果没有带电源适配器,我是不能充电的,因为不可能为了我一个旅客而为我更改墙上的插座,更不可能为我专门盖一座使用中国国标插座的宾馆。因为人家德国人一直这么使用,并且用的挺好,俗话说入乡随俗,我就要自己想办法来解决问题。对应到我们的代码中,也就是说,上面的Hotel类,DBSocket类,DBSocketInterface接口都是不可变的(由德国的客户提供),如果我想使用这一套API,那么只能自己写代码解决。

/** 
 * 国标接口 
 */  
public interface GBSocketInterface {  

    /** 
     * 这个方法的名字叫做:使用三项扁头的插口供电 
     */  
    void powerWithThreeFlat();  
}  

/** 
 * 中国插座 
 */  
public class GBSocket implements GBSocketInterface{  

    @Override  
    public void powerWithThreeFlat() {  
        System.out.println("使用三项扁头插孔供电");  
    }  
}  

可以认为这两个东西是我带到德国去的,目前他们还不能使用,因为接口不一样。那么我必须创建一个适配器,这个适配器必须满足以下条件:

  1. 必须符合德国标准的接口,否则的话还是没办法插到德国插座中;
  2. 在调用上面实现的德标接口进行充电时,提供一种机制,将这个调用转到对国标接口的调用。

这就要求:

  1. 适配器必须实现原有的旧的接口;
  2. 适配器对象中持有对新接口的引用,当调用旧接口时将这个调用委托给实现新接口的对象来处理,也就是在适配器对象中组合一个新接口。
public class SocketAdapter    
        implements DBSocketInterface{   //实现旧接口  

    //组合新接口  
    private GBSocketInterface gbSocket;  

    /** 
     * 在创建适配器对象时,必须传入一个新街口的实现类 
     * @param gbSocket 
     */  
    public SocketAdapter(GBSocketInterface gbSocket) {  
        this.gbSocket = gbSocket;  
    }  


    /** 
     * 将对就接口的调用适配到新接口 
     */  
    @Override  
    public void powerWithTwoRound() {  

        gbSocket.powerWithThreeFlat();  
    }  

}  

这个适配器类满足了上面的两个要求。下面写一段测试代码来验证一下适配器能不能工作:

public class TestAdapter {  

    public static void main(String[] args) {  

        GBSocketInterface gbSocket = new GBSocket();  

        Hotel hotel = new Hotel();  

        SocketAdapter socketAdapter = new SocketAdapter(gbSocket);  

        hotel.setSocket(socketAdapter);  

        hotel.charge();  
    }  
}  

适配器模式就是不改变原有的接口,但是还可以使用新接口功能。

2.10、Collection 和 Collections 有什么区别?

  • java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。Collection接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有List与Set。

  • Collections则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。

2.11、ArrayList

概述

因为 ArrayList 是基于数组实现的,所以支持快速随机访问RandomAccess 接口标识着该类支持快速随机访问

public class ArrayList extends AbstractList
        implements List, RandomAccess, Cloneable, java.io.Serializable

数组的默认大小为 10。

private static final int DEFAULT_CAPACITY = 10;
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第10张图片 https://cyc2018.github.io/

扩容

添加元素时使用 ensureCapacityInternal() 方法来保证容量足够,如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍

扩容操作需要调用 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

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);
}

删除元素

需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上,该操作的时间复杂度为 O(N),可以看到 ArrayList 删除元素的代价是非常高的。

public E remove(int index) {
    rangeCheck(index);
    modCount++;
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}

序列化

ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化

保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化:

transient Object[] elementData; // non-private to simplify nested class access

ArrayList 实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。

private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    elementData = EMPTY_ELEMENTDATA;

    // Read in size, and any hidden stuff
    s.defaultReadObject();

    // Read in capacity
    s.readInt(); // ignored

    if (size > 0) {
        // be like clone(), allocate array based upon size not capacity
        ensureCapacityInternal(size);

        Object[] a = elementData;
        // Read in all elements in the proper order.
        for (int i=0; i
private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out size as capacity for behavioural compatibility with clone()
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (int i=0; i

序列化时需要使用 ObjectOutputStream 的 writeObject() 将对象转换为字节流并输出。而 writeObject() 方法在传入的对象存在 writeObject() 的时候会去反射调用该对象的 writeObject() 来实现序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法,原理类似。

ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);

Fail-Fast

modCount 用来记录 ArrayList 结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。

在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 ConcurrentModificationExc eption。代码参考序列化中的 writeObject() 方法。

2.11、Vector

同步

它的实现与 ArrayList 类似,但是使用了 synchronized 进行同步。

public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

public synchronized E get(int index) {
    if (index >= elementCount)
        throw new ArrayIndexOutOfBoundsException(index);

    return elementData(index);
}

扩容

Vector 的构造函数可以传入 capacityIncrement 参数,它的作用是在扩容时使容量 capacity 增长 capacityIncrement。如果这个参数的值小于等于 0,扩容时每次都令 capacity 为原来的两倍。

public Vector(int initialCapacity, int capacityIncrement) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
    this.elementData = new Object[initialCapacity];
    this.capacityIncrement = capacityIncrement;
}
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 的构造函数时,capacityIncrement 值被设置为 0,也就是说默认情况下 Vector 每次扩容时容量都会翻倍。

public Vector(int initialCapacity) {
    this(initialCapacity, 0);
}

public Vector() {
    this(10);
}

与ArrayList的比较

  • Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
  • Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。

替换方案

可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList。

List list = new ArrayList<>();
List synList = Collections.synchronizedList(list);

也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。

List list = new CopyOnWriteArrayList<>();

CopyOnWriteArrayList读写分离

写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。

写操作需要加锁,防止并发写入时导致写入数据丢失。

写操作结束之后需要把原始数组指向新的复制数组。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

final void setArray(Object[] a) {
    array = a;
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}

CopyOnWriteArrayList适用场

CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。

但是 CopyOnWriteArrayList 有其缺陷:

  • 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
  • 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。

所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。

2.12、LinkedList

概览

基于双向链表实现,使用 Node 存储链表节点信息。

private static class Node {
    E item;
    Node next;
    Node prev;
}

每个链表存储了 first 和 last 指针:

transient Node first;
transient Node last;
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第11张图片 https://cyc2018.github.io/

与ArrayList的比较

ArrayList 基于动态数组实现,LinkedList 基于双向链表实现。ArrayList 和 LinkedList 的区别可以归结为数组和链表的区别:

  • 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
  • 链表不支持随机访问,但插入删除只需要改变指针。

2.13、HashMap

为了便于理解,以下源码分析以 JDK 1.7 为主。

存储结构

内部包含了一个 Entry 类型的数组 table。Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第12张图片 https://cyc2018.github.io/
transient Entry[] table;
static class Entry implements Map.Entry {
    final K key;
    V value;
    Entry next;
    int hash;

    Entry(int h, K k, V v, Entry n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }

    public final K getKey() {
        return key;
    }

    public final V getValue() {
        return value;
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        Object k1 = getKey();
        Object k2 = e.getKey();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        }
        return false;
    }

    public final int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }
}

拉链法工作原理

HashMap map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
  • 新建一个 HashMap,默认大小为 16;
  • 插入 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
  • 插入 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
  • 插入 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 前面。

应该注意到链表的插入是以头插法方式进行的,例如上面的 不是插在 后面,而是插入在链表头部。

查找需要分成两步进行:

  • 计算键值对所在的桶;
  • 在链表上顺序查找,时间复杂度显然和链表的长度成正比。
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第13张图片 https://cyc2018.github.io/

Put操作

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 键为 null 单独处理
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    // 确定桶下标
    int i = indexFor(hash, table.length);
    // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
    for (Entry e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 插入新键值对
    addEntry(hash, key, value, i);
    return null;
}

HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。

private V putForNullKey(V value) {
    for (Entry e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}

使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    // 头插法,链表头部指向新的键值对
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
Entry(int h, K k, V v, Entry n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}

确定桶下标

很多操作都需要先确定一个键值对所在的桶下标。

int hash = hash(key);
int i = indexFor(hash, table.length);

计算Hash值:

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
}

取模:令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x   : 00010000
x-1 : 00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:

y   : 10110010
x   : 00010000
y%x : 00000010

我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

static int indexFor(int h, int length) {
    return h & (length-1);
}

扩容基本原理

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。

为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

 

参数 含义
capacity table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
size 键值对数量。
threshold size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
loadFactor 装载因子,table 能够使用的比例,threshold = (int)(capacity* loadFactor)。
static final int DEFAULT_INITIAL_CAPACITY = 16;

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

transient Entry[] table;

transient int size;

int threshold;

final float loadFactor;

transient int modCount;

从下面的添加元素代码中可以看出,当需要扩容时,令 capacity 为原来的两倍。

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

扩容重新计算桶下标

在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度。

假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:

capacity     : 00010000
new capacity : 00100000

对于一个 Key,它的哈希值 hash 在第 5 位:

  • 为 0,那么 hash%00010000 = hash%00100000,桶位置和原来一致;
  • 为 1,hash%00010000 = hash%00100000 + 16,桶位置是原位置 + 16。

计算数组容量

HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。

先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:

mask |= mask >> 1    11011000
mask |= mask >> 2    11111110
mask |= mask >> 4    11111111

mask+1 是大于原始数字的最小的 2 的 n 次方。

num     10010000
mask+1 100000000

以下是 HashMap 中计算数组容量的代码:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

链表转红黑树

从 JDK 1.8 开始,一个桶存储的链表长度大于等于 8 时会将链表转换为红黑树。(以JDK1.9源码为例)

红黑树:

    /**
     * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
     * extends Node) so can be used as extension of either regular or
     * linked node.
     */
    static final class TreeNode extends LinkedHashMap.Entry {
        TreeNode parent;  // red-black tree links
        TreeNode left;
        TreeNode right;
        TreeNode prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node next) {
            super(hash, key, val, next);
        }

        /**
         * Returns root of tree containing this node.
         */
        final TreeNode root() {
            for (TreeNode r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }

JDK1.8之后Put操作不再直接交由用户执行,而是通过put()调用putVal()来执行:

    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with {@code key}, or
     *         {@code null} if there was no mapping for {@code key}.
     *         (A {@code null} return can also indicate that the map
     *         previously associated {@code null} with {@code key}.)
     */
    public V put(K key, V value) {
        // 对key的hashCode()做hash 
        return putVal(hash(key), key, value, false, true);  
    } 

    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node[] tab; Node p; int n, i;

        // 步骤①:tab为空则创建 
        // table未初始化或者长度为0,进行扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        // 步骤②:计算index,并对null做处理  
        // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

        // 桶中已经存在元素
        else {
            Node e; K k;

            // 步骤③:节点key存在,直接覆盖value 
            // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
            if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))

                // 将第一个元素赋值给e,用e来记录
                e = p;

            // 步骤④:判断该链为红黑树 
            // hash值不相等,即key不相等;为红黑树结点
            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;
                    }

                    // 判断链表中结点的key值与插入的元素的key值是否相等
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                        // 相等,跳出循环
                        break;

                    // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                    p = e;
                }
            }

            // 表示在桶中找到key值、hash值与插入元素相等的结点
            if (e != null) { 

                // 记录e的value
                V oldValue = e.value;

                // onlyIfAbsent为false或者旧值为null
                if (!onlyIfAbsent || oldValue == null)

                    //用新值替换旧值
                    e.value = value;

                // 访问后回调
                afterNodeAccess(e);

                // 返回旧值
                return oldValue;
            }
        }

        // 结构性修改
        ++modCount;

        // 步骤⑥:超过最大容量 就扩容 
        // 实际大小大于阈值则扩容
        if (++size > threshold)
            resize();

        // 插入后回调
        afterNodeInsertion(evict);
        return null;
    }

当有 binCount >= TREEIFY_THRESHOLD - 1 时,执行treeifyBin()将链表转化为红黑树:

/**
 * tab:元素数组,
 * hash:hash值(要增加的键值对的key的hash值)
 */
final void treeifyBin(Node[] tab, int hash) {
 
    int n, index; Node e;
    /*
     * 如果元素数组为空 或者 数组长度小于 树结构化的最小限制
     * MIN_TREEIFY_CAPACITY 默认值64,对于这个值可以理解为:如果元素数组长度小于这个值,没有必要去进行结构转换
     * 当一个数组位置上集中了多个键值对,是因为这些key的hash值和数组长度取模之后结果相同(并不是因为这些key的hash值相同)
     * 因为hash值相同的概率不高,所以可以通过扩容的方式,使这些key的hash值在和新的数组长度取模之后,拆分到多个数组位置上
     */
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); // 扩容,可参见resize方法解析
 
    // 如果元素数组长度已经大于等于了 MIN_TREEIFY_CAPACITY,那么就有必要进行结构转换了
    // 根据hash值和数组长度进行取模运算后,得到链表的首节点
    else if ((e = tab[index = (n - 1) & hash]) != null) { 
        TreeNode hd = null, tl = null; // 定义首、尾节点
        do { 
            TreeNode p = replacementTreeNode(e, null); // 将该节点转换为 树节点
            if (tl == null) // 如果尾节点为空,说明还没有根节点
                hd = p; // 首节点(根节点)指向 当前节点
            else { // 尾节点不为空,以下两行是一个双向链表结构
                p.prev = tl; // 当前树节点的 前一个节点指向 尾节点
                tl.next = p; // 尾节点的 后一个节点指向 当前节点
            }
            tl = p; // 把当前节点设为尾节点
        } while ((e = e.next) != null); // 继续遍历链表
 
        // 到目前为止 也只是把Node对象转换成了TreeNode对象,把单向链表转换成了双向链表
 
        // 把转换后的双向链表,替换原来位置上的单向链表
        if ((tab[index] = hd) != null)
            hd.treeify(tab);//此处单独解析
    }
}

与HashTable的比较

  • Hashtable 使用 synchronized 来进行同步。
  • HashMap 可以插入键为 null 的 Entry。
  • HashMap 的迭代器是 fail-fast 迭代器。
  • HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。

2.14、ConcurrentHashMap

为了便于理解,以下源码分析以 JDK 1.7 为主。

存储结构

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第14张图片 https://cyc2018.github.io/
static final class HashEntry {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry next;
}

ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。

Segment 继承自 ReentrantLock。

static final class Segment extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    static final int MAX_SCAN_RETRIES = Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

    transient volatile HashEntry[] table;

    transient int count;

    transient int modCount;

    transient int threshold;

    final float loadFactor;
}
final Segment[] segments;

默认的并发级别为 16,也就是说默认创建 16 个 Segment。

static final int DEFAULT_CONCURRENCY_LEVEL = 16;

Size操作

每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。

/**
 * The number of elements. Accessed only either within locks
 * or among other volatile reads that maintain visibility.
 */
transient int count;

在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。

ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。

尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。

如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。

/**
 * Number of unsynchronized retries in size and containsValue
 * methods before resorting to locking. This is used to avoid
 * unbounded retries if tables undergo continuous modification
 * which would make it impossible to obtain an accurate result.
 */
static final int RETRIES_BEFORE_LOCK = 2;

public int size() {
    // Try a few times to get accurate count. On failure due to
    // continuous async changes in table, resort to locking.
    final Segment[] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    long sum;         // sum of modCounts
    long last = 0L;   // previous sum
    int retries = -1; // first iteration isn't retry
    try {
        for (;;) {
            // 超过尝试次数,则对每个 Segment 加锁
            if (retries++ == RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {
                Segment seg = segmentAt(segments, j);
                if (seg != null) {
                    sum += seg.modCount;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            // 连续两次得到的结果一致,则认为这个结果是正确的
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}

JDK1.8的改动

JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等。

JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。并且 JDK 1.8 的实现也在链表过长时会转换为红黑树

简单的以Put方法作为对比,以JDK1.9源码为例:

	/** Implementation for put and putIfAbsent */
	final V putVal(K key, V value, boolean onlyIfAbsent) {

		//数据不合法,抛出异常
        if (key == null || value == null) throw new NullPointerException();

        //计算索引的第一步,传入键值的hash值
        int hash = spread(key.hashCode());
        int binCount = 0; //保存当前节点的长度
        for (Node[] tab = table;;) {
            Node f; int n, i, fh; K fk; V fv;

            if (tab == null || (n = tab.length) == 0)
                tab = initTable(); //初始化Hash表

            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {

	            //利用CAS操作将元素插入到Hash表中
                if (casTabAt(tab, i, null, new Node(hash, key, value)))
                    break;  // no lock when adding to empty bin(插入null的节点,无需加锁)
            }

            else if ((fh = f.hash) == MOVED) //f.hash == -1 

	            //正在扩容,当前线程加入扩容
                tab = helpTransfer(tab, f);

            else if (onlyIfAbsent && fh == hash &&  // check first node
                     ((fk = f.key) == key || fk != null && key.equals(fk)) && (fv = f.val) != null)
                return fv;

            else {
                V oldVal = null;

                //当前节点加锁
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;

                            for (Node e = f;; ++binCount) {
                                K ek;

                                //插入的元素键值的hash值有节点中元素的hash值相同,替换当前元素的值
                                if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
	                                    //替换当前元素的值
                                        e.val = value;

                                    break;
                                }
                                Node pred = e;

                                //没有相同的值,直接插入到节点中
                                if ((e = e.next) == null) {
                                    pred.next = new Node(hash, key, value);
                                    break;
                                }
                            }
                        }

                        //节点为树
                        else if (f instanceof TreeBin) {
                            Node p;
                            binCount = 2;
                            if ((p = ((TreeBin)f).putTreeVal(hash, key, value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
	                                //替换旧值
                                    p.val = value;
                            }
                        }
                        else if (f instanceof ReservationNode) 
                                throw new IllegalStateException("Recursive update");
                    }
                }
                if (binCount != 0) {

	                //如果节点长度大于8,转化为树
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal; 
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

简单说说CAS机制

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

看个例子:

内存地址V当中,存储着值为10的变量。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第15张图片

此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第16张图片

在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第17张图片

线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第18张图片

线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第19张图片

这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第20张图片

线程1进行SWAP,把地址V的值替换为B,也就是12。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第21张图片

从思想上来说,Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

                                                                                  ——摘自https://www.cnblogs.com/myopensource/p/8177074.html

CAS的缺点:

  •  CPU开销较大:在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
  •  不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
  • ABA问题:这是CAS机制最大的问题所在。

再简单聊一下ABA问题:

ABA:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。简单举一个场景当例子:

在你非常渴的情况下你发现一个盛满水的杯子,你一饮而尽。
之后再给杯子里重新倒满水。然后你离开,当杯子的真正主人回来时看到杯子还是盛满水,他当然不知道是否被人喝完重新倒满。
解决这个问题的方案的一个策略是每一次倒水假设有一个自动记录仪记录下,
这样主人回来就可以分辨在她离开后是否发生过重新倒满的情况。这也是解决ABA问题目前采用的策略。

而ABA问题会带来什么后果呢?再举一个场景来当例子:

假设小明在提款机提取了50元,因为是假设所以让提款机拥有两个线程,会同时把余额从100变为50;

线程1(提款机):获取当前值100,期望更新为50;
线程2(提款机):获取当前值100,期望更新为50;

这时假设一个场景:线程1成功执行,线程2某种原因block了;刚刚好在这个时间点某人给小明汇款50;

线程3(默认):获取当前值50,期望更新为100;

因为汇款这个操作没毛病,线程3成功执行,余额变为100;

但是!汇款成功后如果刚好线程2从Block中恢复,获取到的也是100,compare之后,继续更新余额为50!!!


此时可以看到,实际余额应该为100(100-50+50),但是因为ABA问题变为了50(100-50+50-50)

这就是ABA问题带来的成功提交!

2.15、LinkedHashMap

存储结构

继承自 HashMap,因此具有和 HashMap 一样的快速查找特性。

public class LinkedHashMap extends HashMap implements Map

内部维护了一个双向链表,用来维护插入顺序或者 LRU 顺序

/**
 * The head (eldest) of the doubly linked list.
 */
transient LinkedHashMap.Entry head;

/**
 * The tail (youngest) of the doubly linked list.
 */
transient LinkedHashMap.Entry tail;

accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序

final boolean accessOrder;

LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。

void afterNodeAccess(Node p) { }
void afterNodeInsertion(boolean evict) { }

afterNodeAccess()

当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。

void afterNodeAccess(Node e) { // move node to last
    LinkedHashMap.Entry last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry p =
            (LinkedHashMap.Entry)e, b = p.before, a = p.after;
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}

afterNodeInsertion()

在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。

evict 只有在构建 Map 的时候才为 false,在这里为 true。

 

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。

protected boolean removeEldestEntry(Map.Entry eldest) {
    return false;
}

LRU缓存

以下是使用 LinkedHashMap 实现的一个 LRU 缓存:

  • 设定最大缓存空间 MAX_ENTRIES 为 3;
  • 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序;
  • 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除。
class LRUCache extends LinkedHashMap {
    private static final int MAX_ENTRIES = 3;

    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }

    LRUCache() {
        super(MAX_ENTRIES, 0.75f, true);
    }
}
public static void main(String[] args) {
    LRUCache cache = new LRUCache<>();
    cache.put(1, "a");
    cache.put(2, "b");
    cache.put(3, "c");
    cache.get(1);
    cache.put(4, "d");
    System.out.println(cache.keySet());
}
[3, 1, 4]

2.16、WeakHashMap

存储结构

WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。

WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。

private static class Entry extends WeakReference implements Map.Entry 
  

ConcurrentCache

Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。

ConcurrentCache 采取的是分代缓存:

  • 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
  • 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收。
  • 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收。
  • 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
public final class ConcurrentCache {

    private final int size;

    private final Map eden;

    private final Map longterm;

    public ConcurrentCache(int size) {
        this.size = size;
        this.eden = new ConcurrentHashMap<>(size);
        this.longterm = new WeakHashMap<>(size);
    }

    public V get(K k) {
        V v = this.eden.get(k);
        if (v == null) {
            v = this.longterm.get(k);
            if (v != null)
                this.eden.put(k, v);
        }
        return v;
    }

    public void put(K k, V v) {
        if (this.eden.size() >= size) {
            this.longterm.putAll(this.eden);
            this.eden.clear();
        }
        this.eden.put(k, v);
    }
}

2.17、关于List

List、Set、Map 之间的区别是什么?

比较 List Set Map
继承接口 Collection Collection  
常见实现类 AbstractList(其常用子类有ArrayList、LinkedList、Vector) AbstractSet(其常用子类有HashSet、LinkedHashSet、TreeSet) HashMap、HashTable
常见方法 add()、remove()、clear()、get()、contains()、size() add()、remove()、clear()、contains()、size() put()、get()、remove()、clear()、containsKey()、containsValue()、KeySet()、values()、size()
元素 可重复 不可重复(用equals()判断) 不可重复
顺序 有序 无序(实际上由HashCode决定)  
线程安全 Vector线程安全   HashTable线程安全

ArrayList 和 Vector 的区别是什么?

  • Vector是同步的,而ArrayList不是。如果寻求在迭代的时候对列表进行改变,应该使用CopyOnWriteArrayList;
  • ArrayList比Vector快,它因为有同步,不会过载;
  • ArrayList更加通用,因为我们可以使用Collections工具类轻易地获取同步列表和只读列表;
  • ArrayList 是 List 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全 ;
  • Vector 是 List 的古老实现类,底层使用 Object[ ]存储,线程安全的;
  • ArrayList与Vector都有一个初始的容量大小,当存储元素个数超过了容量时要增加存储空间,Vector默认增长为原来两倍,而ArrayList是增长为原来的1.5倍;

ArrayList和LinkedList的区别?

  1. 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
  2. 底层数据结构: Arraylist 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别!)
  3. 插入和删除是否受元素位置的影响: 
    ① ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    ② LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n))因为需要先移动到指定位置再插入。
  4. 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  5. 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

双向链表:包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第22张图片

双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第23张图片

RandomAccess接口

public interface RandomAccess {
}

查看源码我们发现实际上 RandomAccess 接口中什么都没有定义。所以,在我看来 RandomAccess 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。

在 binarySearch() 方法中,它要判断传入的 list 是否 RamdomAccess 的实例,如果是,调用indexedBinarySearch()方法,如果不是,那么调用iteratorBinarySearch()方法:

    public static 
    int binarySearch(List> list, T key) {
        if (list instanceof RandomAccess || list.size()

ArrayList 实现了 RandomAccess 接口, 而 LinkedList 没有实现。为什么呢?我觉得还是和底层数据结构有关!ArrayList 底层是数组,而 LinkedList 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。ArrayList 实现了 RandomAccess 接口,就表明了他具有快速随机访问功能。 RandomAccess 接口只是标识,并不是说 ArrayList 实现 RandomAccess 接口才具有快速随机访问功能的! 

2.18、关于Set

comparable和comparator的区别

  • comparable 接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
  • comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序

一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo()方法或compare()方法,当我们需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo()方法和使用自制的Comparator方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().

/*
Comparator定制排序
*/
        ArrayList arrayList = new ArrayList();
        arrayList.add(-1);
        arrayList.add(3);
        arrayList.add(3);
        arrayList.add(-5);
        arrayList.add(7);
        arrayList.add(4);
        arrayList.add(-9);
        arrayList.add(-7);

        System.out.println("原始数组:");
        System.out.println(arrayList);

        // void reverse(List list):反转
        Collections.reverse(arrayList);
        System.out.println("Collections.reverse(arrayList):");
        System.out.println(arrayList);

        // void sort(List list),按自然排序的升序排序
        Collections.sort(arrayList);
        System.out.println("Collections.sort(arrayList):");
        System.out.println(arrayList);

        // 定制排序的用法
        Collections.sort(arrayList, new Comparator() {

            @Override
            public int compare(Integer o1, Integer o2) {
                return o2.compareTo(o1);
            }
        });
        System.out.println("定制排序后:");
        System.out.println(arrayList);
/*
Output
*/

原始数组:
[-1, 3, 3, -5, 7, 4, -9, -7]
Collections.reverse(arrayList):
[-7, -9, 4, 7, -5, 3, 3, -1]
Collections.sort(arrayList):
[-9, -7, -5, -1, 3, 3, 4, 7]
定制排序后:
[7, 4, 3, 3, -1, -5, -7, -9]

重写compareTo方法实现按年龄排序:

/**
 * person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列
 * 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他
 * 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
 */
public  class Person implements Comparable {
    private String name;
    private int age;

    public Person(String name, int age) {
        super();
        this.name = name;
        this.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;
    }

    /**
     * T重写compareTo方法实现按年龄来排序
     */
    @Override
    public int compareTo(Person o) {
        if (this.age > o.getAge()) {
            return 1;
        }
        if (this.age < o.getAge()) {
            return -1;
        }
        return 0;
    }
}
    public static void main(String[] args) {
        TreeMap pdata = new TreeMap();
        pdata.put(new Person("张三", 30), "zhangsan");
        pdata.put(new Person("李四", 20), "lisi");
        pdata.put(new Person("王五", 10), "wangwu");
        pdata.put(new Person("小红", 5), "xiaohong");

        // 得到key的值的同时得到key所对应的值
        Set keys = pdata.keySet();
        for (Person key : keys) {
            System.out.println(key.getAge() + "-" + key.getName());
        }
    }
/*
 *Output
 */
5-小红
10-王五
20-李四
30-张三

无序性和不可重复性的含义是什么?

  • 什么是无序性?无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
  • 什么是不可重复性?不可重复性是指添加的元素按照 equals()判断时 ,返回 false,需要同时重写 equals()方法和 HashCode()方法。

比较HashSet、LinkedHashSet、TreeSet三者的异同

  • HashSet 是 Set 接口的主要实现类 ,HashSet 的底层是 HashMap,线程不安全的,可以存储 null 值;
  • LinkedHashSet 是 HashSet 的子类,能够按照添加的顺序遍历;
  • TreeSet 底层使用红黑树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序。

HashSet如何检查重复

当把对象加入HashSet时,HashSet 会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调用equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让加入操作成功。

                                                                                                                                 ——摘自《Head fist java》第二版 

2.19、关于Map

HashMap 和 HashTable 有什么区别?

  1. 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的,因为 HashTable 内部的方法基本都经过synchronized 修饰。(如果要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  3. 对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同 : 
    ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
    ② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
  6. 方法:hashMap去掉了HashTable 的contains方法,但是加上了containsValue()和containsKey()方法。

HashMap 和 HashSet 有什么区别?

如果你看过 HashSet 源码的话就应该知道:HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()writeObject()readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。

HashMap HashSet
实现了 Map 接口 实现 Set 接口
存储键值对 仅存储对象
调用 put()向 map 中添加元素 调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 Hashcode HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以 equals()方法用来判断对象的相等性

HashMap和TreeMap的区别

TreeMap 和HashMap 都继承自AbstractMap,但是需要注意的是TreeMap还实现了NavigableMap接口和SortedMap 接口。

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第24张图片

  • 实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
  • 实现SortMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。
public class Person {
    private Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }

    public static void main(String[] args) {
        TreeMap treeMap = new TreeMap<>(new Comparator() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}
/*
 Output
 */
person1
person4
person2
person3

可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。

上面是通过传入匿名内部类的方式实现的,可以将代码替换成 Lambda 表达式实现的方式:

TreeMap treeMap = new TreeMap<>((person1, person2) -> {
  int num = person1.getAge() - person2.getAge();
  return Integer.compare(num, 0);
});

综上,相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。

  • 对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。
  • 然而,假如需要对一个有序的key集合进行遍历,TreeMap是更好的选择。
  • 基于collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap进行有序key的遍历。

HashMap的长度为什么时2的幂次方?

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n 代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。

2.20、其他集合问题

什么是fail-fast?

快速失败(fail-fast) 是 Java 集合的一种错误检测机制。在使用迭代器对集合进行遍历的时候,我们在多线程下操作非安全失败(fail-safe)的集合类可能就会触发 fail-fast 机制,导致抛出 ConcurrentModificationException 异常。 另外,在单线程下,如果在遍历过程中对集合对象的内容进行了修改的话也会触发 fail-fast 机制。

注:增强 for 循环也是借助迭代器进行遍历。

举个例子:多线程下,如果线程 1 正在对集合进行遍历,此时线程 2 对集合进行修改(增加、删除、修改),或者线程 1 在遍历过程中对集合进行修改,都会导致线程 1 抛出 ConcurrentModificationException 异常。

为什么呢?

每当迭代器使用 hashNext()/next()遍历下一个元素之前,都会检测 modCount 变量是否为 expectedModCount 值,是的话就返回遍历;否则抛出异常,终止遍历。

如果我们在集合被遍历期间对其进行修改的话,就会改变 modCount 的值,进而导致 modCount != expectedModCount ,进而抛出 ConcurrentModificationException 异常。

注:通过 Iterator 的方法修改集合的话会修改到 expectedModCount 的值,所以不会抛出异常。

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
巨详细的Java面试问题及复习内容(Java基础--第二部分)_第25张图片 阿里巴巴手册相关的规定

我们应该知道:使用 Iterator 提供的 remove 方法,可以修改到 expectedModCount 的值。所以,才不会再抛出ConcurrentModificationException 异常。

那么什么又是fail-safe呢?

明白了快速失败(fail-fast)之后,安全失败(fail-safe)我们就很好理解了。

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。所以,在遍历过程中对原集合所作的修改并不能被迭代器检测到,故不会抛 ConcurrentModificationException 异常。

Arrays.asList()的一些坑

Arrays.asList()在平时开发中还是比较常见的,我们可以使用它将一个数组转换为一个 List 集合。

String[] myArray = { "Apple", "Banana", "Orange" };
List myList = Arrays.asList(myArray);
//上面两个语句等价于下面一条语句
List myList = Arrays.asList("Apple","Banana", "Orange");

JDK 源码对于这个方法的说明:

/**
 *返回由指定数组支持的固定大小的列表。此方法作为基于数组和基于集合的API之间的桥梁,与
 *          Collection.toArray()结合使用。返回的List是可序列化并实现RandomAccess接口。
 */
public static  List asList(T... a) {
    return new ArrayList<>(a);
}

Arrays.asList()将数组转换为集合后,底层其实还是数组:

巨详细的Java面试问题及复习内容(Java基础--第二部分)_第26张图片 《阿里巴巴 Java 开发手册》对其的描述

使用时注意事项

  • 传递的数组必须是对象数组,而不是基本类型。

Arrays.asList()是泛型方法,传入的对象必须是对象数组。

int[] myArray = { 1, 2, 3 };
List myList = Arrays.asList(myArray);
System.out.println(myList.size());//1
System.out.println(myList.get(0));//数组地址值
System.out.println(myList.get(1));//报错:ArrayIndexOutOfBoundsException
int [] array=(int[]) myList.get(0);
System.out.println(array[0]);//1

当传入一个原生数据类型数组时,Arrays.asList() 的真正得到的参数就不是数组中的元素,而是数组对象本身!此时 List 的唯一元素就是这个数组,这也就解释了上面的代码。

我们使用包装类型数组就可以解决这个问题。

Integer[] myArray = { 1, 2, 3 };

 

  • 使用集合的修改方法:add()remove()clear()会抛出异常
List myList = Arrays.asList(1, 2, 3);
myList.add(4);//运行时报错:UnsupportedOperationException
myList.remove(1);//运行时报错:UnsupportedOperationException
myList.clear();//运行时报错:UnsupportedOperationException

Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。

List myList = Arrays.asList(1, 2, 3);
System.out.println(myList.getClass());//class java.util.Arrays$ArrayList

下图是java.util.Arrays$ArrayList的简易源码,我们可以看到这个类重写的方法有哪些。

  private static class ArrayList extends AbstractList
        implements RandomAccess, java.io.Serializable
    {
        ...

        @Override
        public E get(int index) {
          ...
        }

        @Override
        public E set(int index, E element) {
          ...
        }

        @Override
        public int indexOf(Object o) {
          ...
        }

        @Override
        public boolean contains(Object o) {
           ...
        }

        @Override
        public void forEach(Consumer action) {
          ...
        }

        @Override
        public void replaceAll(UnaryOperator operator) {
          ...
        }

        @Override
        public void sort(Comparator c) {
          ...
        }
    }

我们再看一下java.util.AbstractListremove()方法,这样我们就明白为啥会抛出UnsupportedOperationException

public E remove(int index) {
    throw new UnsupportedOperationException();
}

今天这篇文章主要补充了一些昨天没写完的Java基础内容和面经,然后重点整理了Java集合的知识点以及面经问题,y1s1用IDEA看源码真的比较头疼,这还是在我知道HashMap的底层原理的情况下按着思路去看的,还是有一大半云里雾里,最后靠着百度的汉化注释勉勉强强看完了一遍HashMap,但ConcurrentHashMap是真的难顶,Segment分段锁什么的,CAS机制什么的,勉勉强强有点印象了,整理完了还是要每天都看,期待下一篇吧!

附上我的面经和自己整理的问题链接:https://blog.csdn.net/qq_39732867/article/details/105995043

你可能感兴趣的:(Java,Collection,面经,基础)