一文复习Java基础面试知识

申明:本人于公众号Java筑基期,CSDN先后发当前文章,标明原创,转载二次发文请注明转载公众号,另外请不要再标原创 ,注意违规

Java基础知识

1、基本数据类型

在Java中,共有八种基本数据类型,它们分别是:

  1. byte:字节型,占用8位,取值范围为 -128 到 127。

  2. short:短整型,占用16位,取值范围为 -32,768 到 32,767。

  3. int:整型,占用32位,取值范围为 -2,147,483,648 到 2,147,483,647。

  4. long:长整型,占用64位,取值范围为 -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807。

  5. float:单精度浮点型,占用32位,可以表示带小数点的数值。

  6. double:双精度浮点型,占用64位,可以表示更大范围的带小数点的数值。

  7. char:字符型,占用16位,用于表示单个字符,如 ‘A’、‘b’、‘1’ 等。

  8. boolean:布尔型,用于表示逻辑值,只有两个取值:true 和 false。

这些基本数据类型是构建Java程序的基础,它们可以用于声明变量、存储数据和执行各种计算操作。在使用基本数据类型时,需要注意其取值范围和所占用的内存大小,以确保数据的准确性和程序的性能。

1.1 讲讲面试题

看到这里,细心的人肯定会问,为什么有两个浮点型,单精度和双精度又是什么意思?

大家都知道浮点型是一种用于表示带小数点的数值的数据类型。在计算机中,浮点数用于存储实数,即包含整数部分和小数部分的数值。

单精度和双精度是浮点型的两种表示方式,它们分别使用32位和64位存储空间。

  1. 单精度浮点型(float):占用32位,其中1位用于表示符号,8位用于指数,剩下的23位用于尾数。单精度浮点数可以表示大约6到7位有效数字,其表示范围大约在-3.4E38到3.4E38之间。
  2. 双精度浮点型(double):占用64位,其中1位用于表示符号,11位用于指数,剩下的52位用于尾数。双精度浮点数可以表示大约15到16位有效数字,其表示范围大约在-1.7E308到1.7E308之间。

讲到这个,我们就要讲讲实习面试时经常会被问到的一个问题了,为什么不能用浮点型表示金额?

不要小瞧了它,在金融行业,无论几年工作经验,这个行业是一定会问的,生怕刚好面了一个不知道的。

这是因为我们使用浮点型表示金额会涉及到精度丢失的问题。

因为浮点数的表示方式采用二进制表示,某些十进制数在二进制表示中是无限循环的,这会导致精度损失。

所以在进行金融计算等要求精确计算的场景中,精确的小数点后几位是非常重要的,而浮点数的精度问题可能会导致计算结果出现不可预测的误差。

2、自动拆装箱

自动拆装箱是Java中的一种特性,用于在基本数据类型(如int、float等)和对应的包装类型(如Integer、Float等)之间进行自动的转换。自动拆装箱是Java中的一种特性,用于在基本数据类型(如int、float等)和对应的包装类型(如Integer、Float等)之间进行自动的转换。

  1. 基本类型和包装类型:
    1. 基本类型:指的是Java中最原始的数据类型,包括byte、short、int、long、float、double、char和boolean。这些类型在内存中直接存储数值,不具备对象的特性,也没有方法可以调用。
    2. 包装类型:Java为每个基本类型提供了对应的引用类型,称为包装类型。例如,int对应的包装类型是Integer,double对应的包装类型是Double。包装类型是对象,具有一些附加的功能,比如可以调用方法,支持泛型等。
  2. 自动拆装箱:
    1. 自动拆箱(Unboxing)指的是将包装类型自动转换为对应的基本类型。例如,当我们将一个Integer对象赋值给int类型的变量时,会自动将Integer对象的值拆箱为int类型的值。
    2. 自动装箱(Autoboxing)指的是将基本类型自动转换为对应的包装类型。例如,当我们将int类型的值赋值给一个Integer类型的变量时,会自动将int值装箱为Integer对象。
  3. 整形缓存机制:
    1. 在Java中,对于范围在-128到127之间的byte、short、int和char类型的数值,会被缓存起来,以便重复使用。这意味着,当我们创建这些数据类型的对象时,如果值在-128到127之间,将不会每次都创建新的对象,而是直接使用缓存中的对象,从而节省了内存开销。
  4. 基本类型和包装类型选择的场景:
    1. 使用基本类型的优点是它们更高效,占用内存更少,因为它们是直接存储数值的。
    2. 使用包装类型的优点是它们是对象,可以在需要对象的场景中使用,比如集合类、泛型、反射等。同时,包装类型提供了一些有用的方法和功能,比如可以转换为字符串、进行数值操作等。

用代码来说话吧:

场景1:集合类的使用 在Java的集合类中,通常只能存储对象类型(即包装类型),而不能存储基本类型。如果我们需要将一组整数存储在ArrayList中,就需要使用Integer这样的包装类型。

public static void main(String[] args) {
        // 使用基本类型int数组
        int[] intArray = {1, 2, 3, 4, 5};

        // 使用包装类型Integer集合
        ArrayList<Integer> integerList = new ArrayList<>();
        for (int num : intArray) {
            integerList.add(num); // 自动装箱
        }

        // 从集合中取出数据并进行计算
        int sum = 0;
        for (Integer num : integerList) {
            sum += num; // 自动拆箱
        }

        System.out.println("Sum: " + sum);
}

场景2:使用泛型 在使用泛型时,如果要表示一个未知类型的数值,需要使用包装类型作为泛型参数。

public static void main(String[] args) {
    // 使用包装类型Integer作为泛型参数
    ArrayList<Integer> myList = new ArrayList<>();
    myList.add(10); // 自动装箱
    myList.add(20);
    myList.add(30);

    int sum = 0;
    for (Integer num : myList) {
    sum += num; // 自动拆箱
    }

    System.out.println("Sum: " + sum);
}

因此,在需要使用对象的场景下,应该选择包装类型;而在需要高效的数值计算和内存占用较小的场景下,可以选择基本类型。在Java 5及以上版本中,由于引入了自动拆装箱特性,基本类型和包装类型之间的转换会更加方便和自然,开发者不再需要过多关注这些转换的细节。

2.1 讲讲面试题

说到包装类型,用的最多的就是String,就来讲讲关于它最经典的面试题吧:

关于String字符串的不可变性:

在Java中,String是一种不可变的类,即一旦创建了String对象,它的值就不能被修改。这种不可变性是通过以下几个特性来实现的:

  1. String类使用final关键字修饰:String类被声明为final类,意味着它不能被继承,防止子类对其进行修改。
  2. 字符串存储在常量池中:Java中的字符串常量(例如:“Hello”)都是存储在一个被称为常量池(String Pool)的特殊区域内。当创建一个新的字符串时,如果常量池中已经存在相同内容的字符串,则直接返回常量池中的引用,而不是创建新的对象。
  3. 字符串的值不可变:String类中的字符数组被声明为private final char[] value,这使得String对象内部的字符数组不能被外部修改。

不可变性带来了以下好处:

  1. 线程安全:由于字符串是不可变的,多个线程可以安全地共享一个字符串对象而不需要担心数据被修改的问题。
  2. 缓存Hash值:由于字符串的不可变性,String的hashCode()方法可以在第一次计算后缓存该值,加快了哈希表等数据结构的性能。
  3. 安全性:不可变性确保字符串在传递过程中不会被意外修改,从而增加代码的可靠性和安全性。

尽管String本身是不可变的,但我们可以通过创建新的String对象或使用StringBuilder或StringBuffer类来对字符串进行修改或拼接。不可变性是String类设计的核心特点之一,它在Java中有着广泛的应用,例如在字符串处理、缓存、哈希表等方面。

JDK 6和JDK 7中substring的原理及区别

关于String的用法,除了String.valueOf()以外,我用的最多的就是substring()了吧。

  • 在 JDK 6 中,substring 方法会创建一个新的字符串对象,该对象包含原始字符串中指定索引范围的字符。例如,调用 "Hello World".substring(0, 5) 将返回一个新的字符串对象 "Hello"

    public String substring(int beginIndex, int endIndex) {
        // 参数合法性检查
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        // 创建新的字符串对象,复制字符数据
        return ((beginIndex == 0) && (endIndex == value.length)) ? this :
            new String(value, beginIndex, subLen);
    }
    public String(char value[], int offset, int count) {
        this.value = value;
        this.offset = offset;
        this.count = count;
    }
    

    **面试装逼的说:**在JDK 6的实现中,substring 方法通过复制原始字符串中的字符来创建新的字符串对象。这意味着新字符串和原始字符串共享同样的字符数组,即使新字符串只是原始字符串的一部分,整个字符数组仍然被保留在内存中。

    **面试简单的说:**在JDK 6 的实现中,在创建新的字符串对象时,使用了 new String(...) 来复制字符数据,即创建了一个新的字符数组来保存子字符串的内容。

  • 而在 JDK 7 中,substring 方法的实现发生了改变。它不再创建新的字符数组来保存子字符串,而是将原始字符串的字符数组直接引用到新的字符串对象中。这意味着在 JDK 7 中,当调用 substring 方法时,新的字符串对象与原始字符串共享相同的字符数组,不再复制字符数据,从而节省了内存开销。

    public String substring(int beginIndex, int endIndex) {
        // 参数合法性检查
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        // 创建新的字符串对象,共享字符数组
        return ((beginIndex == 0) && (endIndex == value.length)) ? this :
            new String(value, beginIndex, subLen);
    }
    public String(char value[], int offset, int count){
        .....
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }
    

    **面试装逼的说:**和 JDK 6 的实现类似,JDK 7 的 substring 方法也先进行参数合法性检查,确保传入的索引值在合法范围内。然后计算子字符串的长度 subLen。在 JDK 7 的实现中,当 beginIndex 为 0 且 endIndex 等于原始字符串的长度时,表示要获取的子字符串与原始字符串完全相同,此时直接返回原始字符串本身(即 this),而不再创建新的字符串对象。

    **面试简单的说:**JDK 7 的实现中,在创建新的字符串对象时,同样使用了 new String(...),但当 beginIndexendIndex 指定的子字符串与原始字符串不同的情况下,它将共享相同的字符数组,不再复制字符数据。

字符串拼接的⼏种⽅式和区别

一般我会讲4种:

  1. 使用"+"运算符拼接:

    public static void main(String[] args) {
        String str1 = "Hello";
        String str2 = " World";
        String result = str1 + str2;
        System.out.println(result);
    }
    

    这是最简单的字符串拼接方式,使用"+“运算符可以将两个字符串连接成一个新的字符串。然而,当需要拼接多个字符串时,使用”+"运算符会生成大量的临时中间字符串,效率较低

  2. 使用StringBuilder拼接:

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append("Hello");
        sb.append(" World");
        String result = sb.toString();
        System.out.println(result);
    }
    

    StringBuilder类是专门用于字符串拼接的可变字符序列,它的append方法可以高效地在末尾添加字符串。在需要拼接大量字符串时,使用StringBuilder比使用"+"运算符要高效,因为它避免了创建大量的临时字符串。

  3. 使用StringBuffer拼接:

    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        sb.append("Hello");
        sb.append(" World");
        String result = sb.toString();
        System.out.println(result);
    }
    

    StringBuffer与StringBuilder类似,也是可变字符序列,但它是线程安全的。如果在多线程环境下进行字符串拼接,推荐使用StringBuffer,但在单线程情况下,StringBuilder通常性能更好。

  4. 使用String的concat方法:

    public static void main(String[] args) {
        String str1 = "Hello";
        String str2 = " World";
        String result = str1.concat(str2);
        System.out.println(result);
    }
    

    String类提供了concat方法,用于连接两个字符串。和"+"运算符一样,这种方法也会创建大量临时中间字符串,效率较低

    四种的区别:

    • 使用"+"运算符拼接字符串时,会产生大量的临时中间字符串,效率较低,不适合拼接大量字符串。
    • 使用StringBuilder或StringBuffer拼接字符串时,可以避免产生大量的临时中间字符串,效率较高,适合拼接大量字符串。
    • 使用String的concat方法拼接字符串时,效率与"+"运算符类似,也会产生大量的临时中间字符串,不适合拼接大量字符串。

    **面试简单的说:**对于频繁拼接大量字符串的情况,会使用StringBuilder或StringBuffer来实现,以提高性能和效率。

3、关键字

关于关键字,最为主要的是避免将这些关键字用作标识符(如变量名、方法名等),以免引起编译错误。

以下是一些常见的Java关键字:

  1. class: 定义类。
  2. interface: 定义接口。
  3. extends: 继承一个类或实现一个接口。
  4. implements: 实现接口。
  5. public, private, protected: 访问修饰符,用于控制类、方法和属性的访问权限。
  6. static: 用于定义静态方法、静态变量或静态代码块。
  7. final: 常量修饰符,用于表示一个不可修改的值或不可继承的类。
  8. abstract: 抽象类或抽象方法修饰符,用于表示类或方法是抽象的,不能直接实例化。
  9. new: 创建新对象的关键字。
  10. this: 表示当前对象的引用。
  11. super: 表示父类对象的引用。
  12. if, else: 条件语句关键字。
  13. for, while, do: 循环语句关键字。
  14. switch, case, default: 选择语句关键字。
  15. return: 从方法中返回值的关键字。
  16. try, catch, finally: 异常处理关键字。

4、集合类

关于集合类,我们最常见也是用的最多的无非List和Set这两种

  1. List和Set的区别

    1. List也是Java中的一个接口,同样继承自Collection接口,用于表示一个有序的、可重复的元素集合。
    2. Set是Java中的一个接口,它继承自Collection接口,用于表示一组不重复的元素,不允许包含重复的元素。

    主要区别:

    • List中的元素是有序的,会保持插入的顺序,因此可以通过索引来访问元素。
    • List允许包含重复的元素,即相同的元素可以在List中出现多次。
    • Set中的元素是无序的,不会保持插入顺序,因此不能通过索引来访问元素。
    • Set不允许包含重复的元素,即相同的元素只会在Set中保留一个。
  2. Set如何保证元素不重复

    ​ Set接口在实现时,对于添加元素时会根据值来判断是否已经存在相同元素,从而保证元素不重复。准确的说是使用了元素的hashCode方法来计算哈希值,然后寻找是否已经添加了相同哈希值的元素。

  3. Collection和Collections区别

    ​ Collection作为Java中表示集合的接口(interface),它同时也支持泛型Collection,它是List、Set和Queue接口的父接口,定义了集合的基本操作和行为。

    ​ Collections是Java中的一个实用类,它提供了一系列静态方法来操作集合,例如对集合进行排序、查找最大最小值、反转集合等。

4.1 集合的遍历

在Java中,集合类提供了多种遍历方式,可以用来遍历集合中的元素。

List<String> list = new ArrayList<>();
list.add("Apple");
list.add("Banana");
list.add("Orange");

假设有上面一个名为list的集合,以下是常见的集合遍历方式:

  1. 使用Iterator遍历

    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        String element = iterator.next();
        System.out.println(element);
    }
    
  2. 使用for-each循环遍历(增强for循环)

    for (String element : list) {
        System.out.println(element);
    }
    
  3. 使用普通for循环遍历(适用于List)

    for (int i = 0; i < list.size(); i++) {
        String element = list.get(i);
        System.out.println(element);
    }
    
  4. 使用forEach方法(适用于Java 8及以上版本)

    list.forEach(element -> System.out.println(element));
    

注意事项:

  • 遍历Set时,由于Set是无序的,所以无法使用普通for循环遍历。只能使用Iterator、for-each循环或forEach方法。
  • 遍历List时,推荐使用for-each循环或forEach方法,因为它们更简洁、易读,并且不需要手动维护索引变量。
  • 在遍历过程中,如果需要在循环内部对集合进行增加、删除等操作,请使用Iterator,并使用Iterator的remove方法进行安全的操作。
4.2 ArrayList和LinkedList和Vector的区别

面试装逼的说:

ArrayList、LinkedList和Vector都是Java中的集合类,用于存储一组对象。它们有以下区别:

  1. 实现方式:
    • ArrayList是基于动态数组实现的,内部使用数组来存储元素,可以动态扩容和缩容。
    • LinkedList是基于链表实现的,内部使用双向链表来存储元素。
    • Vector也是基于动态数组实现的,和ArrayList类似,但是它是线程安全的,支持同步操作。
  2. 线程安全性:
    • ArrayList和LinkedList是非线程安全的,不支持多线程并发操作,如果需要在多线程环境下使用,需要手动进行同步处理。
    • Vector是线程安全的,支持多线程并发操作,内部的方法都使用了synchronized关键字进行同步,因此相对于ArrayList和LinkedList,Vector的性能较差。
  3. 遍历性能:
    • ArrayList由于基于数组,因此在随机访问元素时效率较高,时间复杂度为O(1)。但在插入或删除元素时需要进行数组的复制和移动,效率较低,时间复杂度为O(n)。
    • LinkedList在插入或删除元素时效率较高,因为只需要改变链表节点的指针指向,时间复杂度为O(1)。但在随机访问元素时效率较低,需要从头节点或尾节点开始遍历,时间复杂度为O(n)。
    • Vector的性能和ArrayList类似,在随机访问元素时效率较高,但在插入或删除元素时效率较低。
  4. 空间占用:
    • ArrayList和Vector都是基于动态数组实现的,因此会预先分配一定的空间,如果元素数量超过了初始分配的空间,需要重新分配更大的空间,会导致内存浪费。
    • LinkedList不需要预先分配连续的内存空间,每个元素通过链表节点链接,因此空间利用率较高。

面试简单的说:

背起来可能有点困难,我再总结,缩减一下:

  1. ArrayList:
    • 基于动态数组实现,支持随机访问元素(时间复杂度为 O(1))。
    • 插入和删除元素时,需要进行数组的复制和移动,效率较低(时间复杂度为 O(n))。
    • 不是线程安全的,适合在单线程环境中使用。
  2. LinkedList:
    • 基于双向链表实现,插入和删除元素时效率较高(时间复杂度为 O(1))。
    • 随机访问元素时效率较低(时间复杂度为 O(n))。
    • 不是线程安全的,适合在单线程环境中使用。
  3. Vector:
    • 基于动态数组实现,类似于 ArrayList,支持随机访问元素。
    • 所有方法都使用 synchronized 关键字进行同步,因此是线程安全的。
    • 由于同步操作,性能较低,在多线程环境下才使用。

Emm…或许还可以这样:

  • 如果需要高效的随机访问,ArrayList是较好的选择。
  • 如果需要频繁的插入和删除操作,LinkedList可以更高效地执行。
  • 而Vector适用于需要线程安全的情况,但在性能上相对较差,通常只在多线程环境下使用。

不能再缩了…

4.3 HashSet、LinkedHashSet和 TreeSet的区别

面试装逼的说:

HashSet、LinkedHashSet和TreeSet都是Java中的Set接口的实现类,用于存储一组不重复的元素。它们之间的主要区别如下:

  1. HashSet:
    • HashSet是基于哈希表实现的,不保证元素的顺序,也不允许包含重复的元素。
    • 在HashSet中,添加元素的顺序可能与它们被存储的顺序不同,因为HashSet使用哈希值来存储和获取元素,哈希值不会按照元素的插入顺序排列。
  2. LinkedHashSet:
    • LinkedHashSet是HashSet的子类,在HashSet的基础上增加了维护元素插入顺序的功能。
    • LinkedHashSet通过双向链表来维护元素的插入顺序,因此在遍历时会按照元素插入的顺序进行输出。
  3. TreeSet:
    • TreeSet是基于红黑树(自平衡二叉查找树)实现的,它会对元素进行排序,并保持排序状态。
    • TreeSet中的元素是有序的,按照元素的自然顺序(或指定的比较器顺序)进行排列。因此,对于元素类型为基本数据类型或实现了Comparable接口的类,默认按照元素自然顺序进行排序。对于没有实现Comparable接口的类,可以通过传入一个Comparator来指定排序方式。

面试简单的说:

再总结,缩减一下:

  • HashSet是最基本的集合实现类,无序且不允许重复元素。
  • LinkedHashSet在HashSet的基础上维护元素插入顺序,保持元素的插入顺序。
  • TreeSet在HashSet的基础上使用红黑树来对元素进行排序,并保持排序状态。

再精华一下就是:

  • 如果需要无序的、不重复的集合,可以使用HashSet。
  • 如果需要按照插入顺序来遍历集合,可以使用LinkedHashSet。
  • 如果需要有序的集合,可以使用TreeSet,并可以通过Comparator来自定义排序方式。
4.4 HashMap、HashTable、ConcurrentHashMap区别

面试装逼的说:

HashMap、HashTable和ConcurrentHashMap都是Java中用于存储键值对的Map接口的实现类。

但它们在线程安全性、同步方式和性能方面有一些区别:

  1. HashMap:
    • HashMap是非线程安全的,不适用于多线程环境。
    • HashMap允许使用null作为键和值。
    • HashMap在插入、查找和删除操作上具有较好的性能,因为它不需要进行同步。
  2. HashTable:
    • HashTable是线程安全的,所有方法都使用 synchronized 关键字进行同步,保证了多线程并发安全。
    • HashTable不允许使用null作为键和值,任何null值都会抛出NullPointerException。
    • HashTable的性能较差,因为在所有方法上都使用了同步锁,即使是单线程环境下,也会有性能损失。
  3. ConcurrentHashMap:
    • ConcurrentHashMap是线程安全的,通过分段锁(Segment)实现,可以支持多个线程并发访问不同的段,从而提高并发性能。
    • ConcurrentHashMap允许使用null作为键或值,但在键和值都不能为null的情况下,效率会更高。
    • ConcurrentHashMap在高并发环境下具有较好的性能,相比HashTable,它提供更好的并发处理能力,适用于高度并发的场景。

面试简单的说:

试着总结,缩减一下:

  • HashMap是非线程安全的,适合在单线程环境下使用。
  • HashTable是线程安全的,但性能较差,多线程环境下使用。
  • ConcurrentHashMap是线程安全的,且具有较好的并发性能,适合高并发环境。

再精华一下就是:

  • 如果在多线程环境下需要进行Map操作,推荐使用ConcurrentHashMap,以保证线程安全和高性能。
  • 在单线程环境下,可以使用HashMap来获得更好的性能。
  • 而HashTable在现代Java应用中较少使用,因为它的性能相对较差,通常可以用ConcurrentHashMap来替代。
4.5 HashMap 的数据结构与扩容机制

看到这道题就有阴影,因为经常被问

大家都知道HashMap是Java中常用的哈希表(散列表)实现。

面试装逼的说:

它是通过数组和链表/红黑树组合的方式来存储键值对,以实现高效的数据存储和查找。而且在HashMap中,键和值都可以为null,且不保证元素的插入顺序。

数据结构:

  1. 数组:HashMap内部使用一个数组来存储键值对。数组的每个元素都是一个链表的头节点(JDK 7中使用的是单向链表,JDK 8中引入了红黑树优化)。
  2. 链表/红黑树:当多个键哈希到同一个数组索引位置时,它们会以链表/红黑树的形式存储在该位置。链表适用于小规模冲突,红黑树用于解决大规模冲突(JDK 8中引入)。

扩容机制: HashMap在插入键值对时,会根据其哈希值计算其在数组中的索引位置,然后将键值对存储在该位置。当元素数量达到一定阈值(负载因子)时,HashMap会触发扩容操作,以保持数组的填充因子不超过预设值,从而保持较好的查找性能。

扩容操作:

  1. 创建新数组:当HashMap需要扩容时,会创建一个新的数组,其长度是原数组的两倍。
  2. 重新计算哈希值:所有原有的键值对会根据新数组的长度进行重新计算哈希值,并放入新的数组中对应的位置。
  3. 处理冲突:由于哈希值重新计算后,原本哈希值不同但新的哈希值相同的键值对会出现在同一个数组位置上,因此需要解决冲突。这里会涉及链表转换成红黑树(JDK 8引入)等操作。
  4. 将新数组代替旧数组:扩容完成后,新的数组会代替旧的数组,这样HashMap就完成了扩容操作。

扩容的过程会比较耗时,但是通过扩容可以保证HashMap在不断增加元素时,仍能保持较好的查找性能。负载因子是影响扩容触发的重要因素,一般情况下,当HashMap中的元素数量达到容量乘以负载因子时,就会触发扩容操作,默认负载因子为0.75,这也是JDK中HashMap的默认值。

面试简单的说(说真的简单不起来):

总结一下:

  1. HashMap使用数组和链表/红黑树的结合来实现高效的数据存储和查找。在插入键值对时,通过哈希值计算键的索引位置,并将键值对存储在该位置。当元素数量达到负载因子(默认为0.75)乘以容量时,HashMap会触发扩容操作,即创建新数组、重新计算哈希值、解决冲突,最终将新数组代替旧数组完成扩容。这样可以保持较好的查找性能,并提高HashMap的效率和性能。
  2. 但是需要注意的是,尽管HashMap在单个操作上是线程安全的,但在多线程环境下仍需谨慎,特别是在扩容过程中,可能导致链表或红黑树出现环形结构的问题。为了在多线程环境中保证安全使用,可以使用ConcurrentHashMap或通过显式的同步措施来进行保护。
4.6 HashMap 中 size 和 capacity 的区别

在HashMap中,size和capacity是两个不同的概念,甚至用得少的只知道Size,而不知Capacity,拿下面的这份代码来举例:

HashMap<String, Integer> hashMap = new HashMap<>(16);
hashMap.put("apple", 1);
hashMap.put("banana", 2);
hashMap.put("orange", 3);
  1. Size(大小):

    ​ 大家都知道因为已经存储了三个键值对在里面了,所以当前hashMap的size是3。

  2. Capacity(容量):

    ​ 而Capacity的大小是16,表示HashMap内部数组的长度,即HashMap能够容纳键值对的槽数量。一般这个大小会是2的幂次方,如16、32、64等等。且在HashMap的实现中,capacity会随着元素的增加而动态改变。当元素数量达到负载因子(默认为0.75)乘以容量时,HashMap会触发扩容操作,将capacity翻倍,以保持较好的性能。扩容后,HashMap会重新计算哈希值,并将元素重新分配到新的更大数组中。

总结:

  • Size是指HashMap中当前存储的键值对的数量,可以通过size()方法获取。
  • Capacity是指HashMap内部数组的长度,总是2的幂次方,可以通过capacity()方法获取。
  • Capacity会随着元素的增加而动态改变,触发扩容操作以保持较好的性能。
4.7 loadFactor(负载因子)和threshold(阈值)

在HashMap中,loadFactor(负载因子)和threshold(阈值)是用于控制HashMap扩容的重要参数

  1. LoadFactor(负载因子):

    负载因子是一个表示HashMap在什么时候进行扩容的系数。它是HashMap中实际元素数量与容器大小(数组长度)的比率。默认情况下,负载因子是0.75。

    公式:负载因子 = 实际元素数量 / 容器大小

    当实际元素数量达到负载因子乘以容器大小时,即 size >= loadFactor * capacity,HashMap会触发扩容操作。负载因子越大,意味着HashMap在容器未满的情况下就会进行扩容,减少哈希冲突的可能性。但同时,较大的负载因子会增加空间的浪费。负载因子较小时,HashMap需要更频繁地进行扩容,但空间利用率更高。

  2. Threshold(阈值):

    阈值是实际元素数量超过多少时,HashMap会进行扩容的具体阈值。它是负载因子乘以容器大小,即 threshold = loadFactor * capacity

    当HashMap中的元素数量达到阈值时,会触发扩容操作,将HashMap的容器大小翻倍,以保持较好的性能。新的容器大小为原容器大小的两倍。

    公式:阈值 = 负载因子 * 容器大小

总结:通过调整负载因子和容器大小,可以在空间利用率和性能之间进行权衡。通常情况下,默认的负载因子0.75是一个较为合理的选择,同时也建议初始化HashMap时指定容器大小,以避免过多的扩容操作。

4.8 指定容量

常常会看到提示,建议我们new HashMap()时,为HashMap指定容量,比如刚刚的:

HashMap<String, Integer> hashMap = new HashMap<>(16);

面试简单的说:

主要原因是为了提高性能和避免不必要的扩容操作。

面试复杂的说:

  1. 提高性能: 在创建集合时,如果知道预期的元素数量,可以通过指定合适的容量大小,让集合初始时就具备足够的空间来存储元素。这样可以减少集合在插入元素时的扩容次数,从而提高插入元素的性能。如果预先知道集合可能存储的元素数量,直接将容量设置为预期数量,可以避免多次扩容。
  2. 减少内存浪费: 如果不指定容量,集合会使用默认的初始容量。在集合元素逐渐增加的过程中,如果容量不够,会触发扩容操作,将容量翻倍,这可能会造成一定的内存浪费。而如果能够根据预期的元素数量来初始化集合的容量,可以避免过多的内存浪费。

而且,容量的设置并不会影响集合的逻辑功能,只是在性能和内存利用方面有所优化。过小的容量可能导致频繁的扩容,过大的容量可能会浪费内存,因此建议根据预期的元素数量来合理设置集合的容量。

通过4.8近而衍生出另一个问题

4.9 HashMap 的初始容量设置成多少合适?

大家都知道…咳咳,这道是送命题,为啥,因为我送过。这道题,如果你回答出一个值,任何值都是错误的。

因为初始容量设置成多少合适,取决于你对元素数量的预估和性能需求。一般情况下,使用你预计的值,除以默认的负载因子0.75,所得的值作为初始容量最合适。比如我认为这个功能大概会有100个数据,那么100/0.75=133,那么我就会创建:

HashMap<String, Integer> hashMap = new HashMap<>(133);

使用133作为Capacity的值。

面试简单的说:

使用预计的总数值,除以默认的负载因子0.75,所得的数值作为初始容量最合适,然后根据具体场景和数据量的预估,再适当进行调整,减少扩容的次数。

4.10 默认负载因⼦=0.75

大家都知道默认负载因⼦为0.75,有很多人好奇,为什么默认负载因⼦会设置为0.75,这个跟内存利用率、性能、哈希冲突都有关系:

  1. 内存利用率:0.75的负载因子在元素数量和容器大小的比率上取得了一个较好的平衡,使得当HashMap中的元素个数达到容器大小的3/4时,就会触发扩容操作。这样可以在一定程度上避免过多的内存浪费。
  2. 性能:较小的负载因子可以减少哈希冲突的概率,提高HashMap的性能。但同时,过小的负载因子会导致容器过早地进行扩容,影响性能。0.75的负载因子在性能和扩容频率之间找到了一个较好的平衡点。
  3. 哈希冲突:较小的负载因子可以减少哈希冲突的发生频率,减少链表或红黑树的长度,提高查找元素的效率。当哈希冲突较少时,链表或红黑树的长度较短,查找速度更快。

面试简单的说:

背不下的背这个:

0.75的负载因子在大多数场景下能够提供较好的性能和空间利用率。

4.11 HashMap 的线程安全问题

大家都知道HashMap是非线程安全的容器,尤其是在多线程的情况下,对同一个HashMap对象进行插入、删除、修改操作时,会导致数据不一致和丢失问题。

对策就是使用ConcurrentHashMap,估计差不多是所有人都会背的一个了。

但是其实,你也可以用另外一种方式,这种面试官也会经常问,如果不用ConcurrentHashMap,你该怎么办?讲不出来?那你就GG了。

第一种:

  • 使用Collections.synchronizedMap获取同步map
Map<String, Integer> synchronizedMap = Collections.synchronizedMap(new HashMap<>());

第二种:

  • 使用synchronized
Map<String, Integer> hashMap = new HashMap<>();
synchronized (hashMap) {
    // 对hashMap进行操作
}

当然了,装逼归装逼,为了保证多线程下的hashmap的数据一致性,我还是会乖乖使用ConcurrentHashMap,并且ConcurrentHashMap的效率也比较高。别问我为什么,因为只要你有过生产环境出问题,客户在现场参观,甲方一直催的经历的话,你也会乖乖使用ConcurrentHashMap。

4.12 HashMap 转红黑树的阈值设置为 8

大家应该都遇过,面试关于HashMap的时候,总会问你转红黑树的阈值问题。

在JDK 8中,Java对HashMap进行了一些优化,其中一个改进是引入了红黑树来优化链表过长的问题。当链表长度为8时,HashMap会将链表转成红黑树,以便提高查找和插入的性能。

别问为什么?这是开发者经过实验和性能测试得出的一个经验值,链表长度达到8的概率较低,此时转换为红黑树可以显著减少查找元素的时间复杂度,从而提高HashMap的性能。

链表长度较小时,使用链表进行查找是较为高效的。因为链表进行插入和查找的时间复杂度都是O(1),但是一旦链表过长,查找时间会变成O(n),n为长度。而红黑树查找的时间复杂度为O(log n),明显时间较短,性能更高。但是如果使用红黑树存储较少数据的时候,红黑树额外的开销会影响性能。红黑树相对于链表来说,存储每个节点需要更多的内存空间,因为红黑树需要维护额外的颜色信息、左右子节点指针等。在元素较少的情况下,使用红黑树可能会占用更多的内存。且插入和删除还需要进行自平衡调整,这些都是额外开销。因此,将链表转换为红黑树是在平衡链表长度和查找性能之间进行的权衡,而8作为阈值在大多数场景中表现较好。

总结一下在JDK 8中HashMap将链表转换为红黑树的阈值设置为8的原因:

  1. 性能优化:JDK 8对HashMap进行了优化,引入了红黑树来解决链表过长导致查找性能下降的问题。
  2. 阈值设置:当某个桶中的链表长度达到8时,HashMap会将链表转换为红黑树,以提高查找和插入的性能。
  3. 经验值:将阈值设置为8是经过实验和性能测试得出的经验值。在典型的应用场景中,链表长度达到8的概率较低,此时转换为红黑树可以显著减少查找时间。
  4. 权衡:将链表转换为红黑树是在链表长度和查找性能之间进行的权衡。较短的链表使用链表进行查找是高效的,较长的链表使用红黑树可以提高性能。

总体来说,将链表转换为红黑树的阈值设置为8在大多数场景中表现较好,能够在提高查找性能的同时,不引入过多的额外开销。不过,具体的阈值设置也可以根据实际应用场景进行调整。HashMap在JDK 8中的这一改进使其在大规模数据和高并发场景下的性能得到显著提升。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值