# JDK面试问题

Collection 是一个集合接口。 它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。是list,set等的父接口。 Collections 是一个包装类。 它包含有各种有关集合操作的静态多态方法。此类不能实例化,就像一个工具类,服务于Java的Collection框架。 日常开发中,不仅要了解Java中的Collection及其子类的用法,还要了解Collections用法。

# Arraylist和Linkedlist的区别

  • Arraylist: 底层是基于动态数组,根据下表随机访问数组元素的效率高,向数组尾部添加元素的效率高;但是,删除数组中的数据以及向数组中间添加数据效率低,因为需要移动数组。
  • Arraylist:它的默认初始化长度为 10;同时它也是支持动态扩容的,通过这个方法进行动态扩容新增加的容量大小为原容量大小的50%。 底层调用的就是Arrays.copyOf(elementData,newCapacity),
  • Linklist: 基于链表的动态数组,数据添加删除效率高,只需要改变指针指向即可,但是访问数据的平均效率低,需要对链表进行遍历.
  • LinkedList:可知该链表是双向链表,即可以从头遍历到尾,也可以从尾遍历到头。同样它也是线程不安全的,在这里最可能的造成的并发原因就是链表成环。

# Hahmap底层数据结构

HashMap 基于 hashing 原理,我们通过 put()和 get()方法储存和获取对象。当我们将键值对传递给 put()方法时, 它调用键对象的 hashCode()方法来计算 hashcode,让后找到 bucket 位置来储存值对象。当获取对象时, 通过键对象的 equals()方法找到正确的键值对,然后返回值对象。HashMap 使用链表来解决碰撞问题,当发生碰撞了, 对象将会储存在链表的下一个节点中。 HashMap 在每个链表节点中储存键值对对象。当两个不同的键对象的 hashcode 相同时会发生什么? 它们会储存在同一个 bucket 位置的链表中。键对象的 equals()方法用来找到键值对。

img.png

Hashmap 中解决 hash 冲突的方式的是采用还数组+链表的结构。在链表的插入操作的时候是在头部进行插入的时候更快的。 而不是在链表的尾部,因为链表是需要遍历才能找到尾部的。在JDK1.7 中的时候 hashmap 中的采用的结构是数组+链表,但是还是存在效率的问题。 就是在遍历的链表的时候需要很长的时间。如果是需要采用的二叉树或者是排序树的时候可能有存在当二叉树退化为为链表的时候和严重的性能时间。 所以 JDK1.8 中采用的是数组+链表+红黑树一种数据结构。

HashMap 中初始化大小为什么是 16? 为什么链表的长度为 8 是变成红黑树?为什么为 6 时又变成链表?

链表长度大于八的条件,实际上是需要两个条件的:链表长度大于 8,官方源码如下:

if (binCount >= TREEIFY THRESHOLD - 1)
    treeifyBin(tab, hash);

当满足条件 1 以后 调用 treeifyBin 方法转化红黑树 。该方 法中,数组如 果长度 小于MIN_TREEIFY_CAPACITY(64)就选择扩容,而不是转化为红黑树。

final void treeifyBin(Node<K, V>[] tab, int hash){
        int n,index;
        Node<K, V> e;
        if(tab==null ll(n=tab.length)<MIN_ TREEIFY_CAPACITY)
        resize();
}

# Hashmap的扩容机制

装载因子,是一个 0-1 之间的系数,根据它来确定需要扩容的阈值,默认值是 0.75,当数据的大于真个数组的 0.75 倍的时候真个时候扩大为原来的 2 倍在将原来的数据复制到性的数据中。

负载因子为什么是 0.75?

当负载因子是 1.0 的时候,也就意味着,只有当数组的 8 个值(这个图表示了 8 个)全部填充了,才会发生扩容。这就带来了很大的问题,因为 Hash 冲突时避免不了的。 当负载因子是 1.0的时候,意味着会出现大量的 Hash 的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。

为什么不采用 AVL 树二采用的是的是红黑树?

红黑树和 AVL 树都是最常用的平衡二叉搜索树,它们的查找、删除、修改都是 Olong(n);

  1. AVL 树是更加严格的平衡,因此可以提供更快的查找速度,一般读取查找密集型任务,适用AVL 树。
  2. 红黑树更适合于插入修改密集型任务。
  3. AVL 树的旋转比红黑树的旋转更加难以平衡和调试。AVL 以及红黑树是高度平衡的树数据结构。它们非常相似,真正的区别在于在任何添加/删除操作时完成的旋转操作次数。

AVL 树和红黑树有几点比较和区别

  1. AVL 以及红黑树是高度平衡的树数据结构。它们非常相似,真正的区别在于在任何添加/删除操作时完成的旋转操作次数。
  2. 两种实现都缩放为 O(lg(N)),其中 N 是叶子的数量,但实际上 AVL 树在查找密集型任务上更快:利用更好的平衡,树遍历平均更短。 另一方面,插入和删除方面,AVL 树速度较慢:需要更高的旋转次数才能在修改时正确地重新平衡数据结构。
  3. 在 AVL 树中,从根到任何叶子的最短路径和最长路径之间的差异最多为 1。在红黑树中,差异可以是 2 倍。
  4. 两个都给 O(log n)查找,但平衡 AVL 树可能需要 O(log n)旋转,而红黑树将需要最多两次旋转使其达到平衡(尽管可能需要检查 O(log n)节点以确定旋转的位置)。旋转本身是O(1)操作,因为你只是移动指针。

# Hashmap多线程的安全问题

可能产生元素丢失的现象 考虑在多线程下 put 操作时,执行 addEntry(hash, key, value, i),如果有产生哈希碰撞,导致两个线程得到同样的bucketIndex 去存储,就可能会出现覆盖丢失的情况。

put 非 null 元素后 get 出来的却是 null

在原理的链表的数据的时候,可能会存放在新节点的因为 hash 值是 hash(key)%(n-1)来表示:在当一个线程遍历的是时候其他的线程获取到了这个值 这将这里 null 值放置新的为链表下。所以可能会造成是的 null 的值

单链表闭环

多线程put后可能导致get死循环。CPU 利用率过高一般是因为出现了出现了死循环,导致部分线程一直运行,占用cpu 时间。 那么 JDk1.7 中使用头插法新的 hash 桶会倒置原 hash 桶中的单链表,插入在多个线程同时扩容的情况下就可能导致产生一个存在闭环的单链表,从而导致死循环在。 改进的方法:JDK1.8 中的采用的是在尾部进行增加的元素。而不是在链头部。

为什么使用分段锁可以来用解决的这个hashmap中的数据丢失的问题?

分段锁是确保每一段的数据都是数据的安全。这样全局都是安全按的。这样在数据做扩容的或者是移动的时候是不予许有其他线程的使用。从而能够保证数据安全

# ConcurrentHashMap原理

ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。整个 ConcurrentHashMap 由一个个 Segment 组成, Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个 segment。 ConcurrentHashMap 是一个 Segment 数组 Segment 通过继承 ReentrantLock来进行加锁,所以每次需要加锁的操作锁住的是一个 segment, 这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

img.png

ConcurrentHashMap在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node+CAS+Synchronized 来保证并发安全进行实现, synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。

# List主要实现类

List主要有ArrayList、LinkedList与Vector几种实现。这三者都实现了List 接口,使用方式也很相似,主要区别在于因为实现方式的不同,所以对不同的操作具有不同的效率。

  • ArrayList 是一个可改变大小的数组.当更多的元素加入到ArrayList中时,其大小将会动态地增长.内部的元素可以直接通过get与set方法进行访问,因为ArrayList本质上就是一个数组.
  • LinkedList 是一个双链表,在添加和删除元素时具有比ArrayList更好的性能.但在get与set方面弱于ArrayList.
  • Vector 和ArrayList类似,但属于强同步类。如果你的程序本身是线程安全的(thread-safe,没有在多个线程之间共享同一个集合/对象),那么使用ArrayList是更好的选择。
  • Vector和ArrayList在更多元素添加进来时会请求更大的空间。Vector每次请求其大小的双倍空间,而ArrayList每次对size增长50%.
  • 而 LinkedList 还实现了 Queue 接口,该接口比List提供了更多的方法,包括 offer(),peek(),poll()等.
  • 注意: 默认情况下ArrayList的初始容量非常小,所以如果可以预估数据量的话,分配一个较大的初始值属于最佳实践,这样可以减少调整大小的开销。

# Set和List区别

List,Set都是继承自Collection接口。都是用来存储一组相同类型的元素的。

  • List特点:元素有放入顺序,元素可重复 。有顺序,即先放入的元素排在前面。
  • Set特点:元素无放入顺序,元素不可重复。无顺序,即先放入的元素不一定排在前面。 不可重复,即相同元素在set中只会保留一份。 所以,有些场景下,set可以用来去重。 不过需要注意的是,set在元素插入时是要有一定的方法来判断元素是否重复的。

# String的字符串不可变性

String在Java中特别常用,而且我们经常要在代码中对字符串进行赋值和改变他的值,但是,为什么我们说字符串是不可变的呢? 首先,我们需要知道什么是不可变对象?不可变对象是在完全创建后其内部状态保持不变的对象。这意味着,一旦对象被赋值给变量, 我们既不能更新引用,也不能通过任何方式改变内部状态。可是有人会有疑惑,String为什么不可变,我的代码中经常改变String的值啊,如下:

String s = "abcd";
s = s.concat("ef");

这样,操作,不就将原本的"abcd"的字符串改变成"abcdef"了么? 但是,虽然字符串内容看上去从"abcd"变成了"abcdef",但是实际上,我们得到的已经是一个新的字符串了。

img.png

如上图,在堆中重新创建了一个"abcdef"字符串,和"abcd"并不是同一个对象。 所以,一旦一个string对象在内存(堆)中被创建出来,他就无法被修改。而且,String类的所有方法都没有改变字符串本身的值,都是返回了一个新的对象。 如果我们想要一个可修改的字符串,可以选择StringBuffer 或者 StringBuilder这两个代替String。

为什么String要设计成不可变

在知道了"String是不可变"的之后,大家是不是一定都很疑惑:为什么要把String设计成不可变的呢?有什么好处呢? 这个问题,困扰过很多人,甚至有人直接问过Java的创始人James Gosling。在一次采访中James Gosling被问到什么时候应该使用不可变变量,他给出的回答是: 其实,主要是从缓存、安全性、线程安全和性能等角度触发的。

  1. 缓存

字符串是使用最广泛的数据结构。大量的字符串的创建是非常耗费资源的,所以,Java提供了对字符串的缓存功能,可以大大的节省堆空间。 JVM中专门开辟了一部分空间来存储Java字符串,那就是字符串池。通过字符串池,两个内容相同的字符串变量,可以从池中指向同一个字符串对象,从而节省了关键的内存资源。

String s = "abcd";
String s2 = s;

对于这个例子,s和s2都表示"abcd",所以他们会指向字符串池中的同一个字符串对象:

img_1.png

但是,之所以可以这么做,主要是因为字符串的不变性。试想一下,如果字符串是可变的,我们一旦修改了s的内容,那必然导致s2的内容也被动的改变了,这显然不是我们想看到的。

  1. 安全性

字符串在Java应用程序中广泛用于存储敏感信息,如用户名、密码、连接url、网络连接等。JVM类加载器在加载类的时也广泛地使用它。 因此,保护String类对于提升整个应用程序的安全性至关重要。当我们在程序中传递一个字符串的时候,如果这个字符串的内容是不可变的,那么我们就可以相信这个字符串中的内容。 但是,如果是可变的,那么这个字符串内容就可能随时都被修改。那么这个字符串内容就完全不可信了。这样整个系统就没有安全性可言了.

  1. 线程安全

不可变会自动使字符串成为线程安全的,因为当从多个线程访问它们时,它们不会被更改。 因此,一般来说,不可变对象可以在同时运行的多个线程之间共享。它们也是线程安全的,因为如果线程更改了值, 那么将在字符串池中创建一个新的字符串,而不是修改相同的值。因此,字符串对于多线程来说是安全的。

  1. hashcode缓存

由于字符串对象被广泛地用作数据结构,它们也被广泛地用于哈希实现,如HashMap、HashTable、HashSet等。在对这些散列实现进行操作时,经常调用hashCode()方法。 不可变性保证了字符串的值不会改变。因此,hashCode()方法在String类中被重写,以方便缓存,这样在第一次hashCode()调用期间计算和缓存散列,并从那时起返回相同的值。

  1. 性能

前面提到了的字符串池、hashcode缓存等,都是提升性能的提现。 因为字符串不可变,所以可以用字符串池缓存,可以大大节省堆内存。而且还可以提前对hashcode进行缓存,更加高效 由于字符串是应用最广泛的数据结构,提高字符串的性能对提高整个应用程序的总体性能有相当大的影响。

# substring的原理及区别

String是Java中一个比较基础的类,每一个开发人员都会经常接触到。而且,String也是面试中经常会考的知识点。 String有很多方法,有些方法比较常用,有些方法不太常用。今天要介绍的substring就是一个比较常用的方法,而且围绕substring也有很多面试题。

substring(int beginIndex, int endIndex)方法在不同版本的JDK中的实现是不同的。了解他们的区别可以帮助你更好的使用他。 为简单起见,后文中用substring()代表substring(int beginIndex, int endIndex)方法。

substring() 的作用 substring(int beginIndex, int endIndex)方法截取字符串并返回其[beginIndex,endIndex-1]范围内的内容。

img_2.png

然而,这个图不是完全正确的表示堆中发生的事情。因为在jdk6 和 jdk7中调用substring时发生的事情并不一样。

JDK 6中的substring

String是通过字符数组实现的。在jdk 6 中,String类包含三个成员变量:char value[], int offset,int count。他们分别用来存储真正的字符数组,数组的第一个位置索引以及字符串中包含的字符个数。 当调用substring方法的时候,会创建一个新的string对象,但是这个string的值仍然指向堆中的同一个字符数组。这两个对象中只有count和offset 的值是不同的。

img_3.png

下面是证明上说观点的Java源码中的关键代码:

//JDK 6
String(int offset, int count, char value[]) {
    this.value = value;
    this.offset = offset;
    this.count = count;
}

public String substring(int beginIndex, int endIndex) {
    //check boundary
    return  new String(offset + beginIndex, endIndex - beginIndex, value);
}

JDK 6中的substring导致的问题

如果你有一个很长很长的字符串,但是当你使用substring进行切割的时候你只需要很短的一段。这可能导致性能问题,因为你需要的只是一小段字符序列, 但是你却引用了整个字符串(因为这个非常长的字符数组一直在被引用,所以无法被回收,就可能导致内存泄露)。 在JDK 6中,一般用以下方式来解决该问题,原理其实就是生成一个新的字符串并引用他。x = x.substring(x, y) + ""

JDK 7 中的substring

上面提到的问题,在jdk 7中得到解决。在jdk 7 中,substring方法会在堆内存中创建一个新的数组。

img_4.png

//JDK 7
public String(char value[], int offset, int count) {
    //check boundary
    this.value = Arrays.copyOfRange(value, offset, offset + count);
}

public String substring(int beginIndex, int endIndex) {
    //check boundary
    int subLen = endIndex - beginIndex;
    return new String(value, beginIndex, subLen);
}

以上是JDK 7中的subString方法,其使用new String创建了一个新字符串,避免对老字符串的引用。从而解决了内存泄露问题。 所以,如果你的生产环境中使用的JDK版本小于1.7,当你使用String的subString方法时一定要注意,避免内存泄露。

# String对+的重载

Java中,想要拼接字符串,最简单的方式就是通过"+"连接两个字符串。有人把Java中使用+拼接字符串的功能理解为运算符重载。 其实并不是,Java是不支持运算符重载的。这其实只是Java提供的一个语法糖。

String wechat = "zhuangxiaoyan";
String introduce = "每日更新Java相关技术文章";
String hollis = wechat + "," + introduce;

反编译后的内容如下,反编译工具为jad。

String wechat = "Hollis";
String introduce = "\u6BCF\u65E5\u66F4\u65B0Java\u76F8\u5173\u6280\u672F\u6587\u7AE0";
String hollis = (new StringBuilder()).append(wechat).append(",").append(introduce).toString();

通过查看反编译以后的代码,我们可以发现,原来字符串常量在拼接过程中,是将String转成了StringBuilder后,使用其append方法进行处理的。 那么也就是说,Java中的+对字符串的拼接,其实现原理是使用StringBuilder.append。 但是,String的使用+字符串拼接也不全都是基于StringBuilder.append,还有种特殊情况,那就是如果是两个固定的字面量拼接,如:String s = "a" + "b" 编译器会进行常量折叠(因为两个都是编译期常量,编译期可知),直接变成 String s = "ab"。

# String拼接方式

常用的字符串拼接方式有五种,分别是使用+、使用concat、使用StringBuilder、使用StringBuffer以及使用StringUtils.join。 由于字符串拼接过程中会创建新的对象,所以如果要在一个循环体中进行字符串拼接,就要考虑内存问题和效率问题。 因此,经过对比,我们发现,直接使用StringBuilder的方式是效率最高的。因为StringBuilder天生就是设计来定义可变字符串和字符串的变化操作的。但是,还要强调的是:

  1. 如果不是在循环体中进行字符串拼接的话,直接使用+就好了。
  2. 如果在并发场景中进行字符串拼接的话,要使用StringBuffer来代替StringBuilder。

还有一个重要的拼接方式我没有介绍,那就是Java 8中提供的StringJoiner ,本文就来介绍一下这个字符串拼接的新兵。 StringJoiner是java.util包中的一个类,用于构造一个由分隔符分隔的字符序列(可选),并且可以从提供的前缀开始并以提供的后缀结尾。 虽然这也可以在StringBuilder类的帮助下在每个字符串之后附加分隔符,但StringJoiner提供了简单的方法来实现,而无需编写大量代码。

StringJoiner类共有2个构造函数,5个公有方法。其中最常用的方法就是add方法和toString方法,类似于StringBuilder中的append方法和toString方法。

StringJoiner的用法比较简单,下面的代码中,我们使用StringJoiner进行了字符串拼接。

public class StringJoinerTest {

    public static void main(String[] args) {
        StringJoiner sj = new StringJoiner("Hollis");

        sj.add("zhuangxiaoyan");
        sj.add("Java干货");
        System.out.println(sj.toString());

        StringJoiner sj1 = new StringJoiner(":","[","]");

        sj1.add("zhuangxiaoyan").add("zhuangxiaoyan").add("Java干货");
        System.out.println(sj1.toString());
    }
}
--------以上代码输出结果:------------------
        zhuangxiaoyanchuangzhuangxiaoyanJava干货
        [zhuangxiaoyan:zhuangxiaoyan:Java干货]

值得注意的是,当我们StringJoiner(CharSequence delimiter)初始化一个StringJoiner的时候,这个delimiter其实是分隔符,并不是可变字符串的初始值。 StringJoiner(CharSequence delimiter,CharSequence prefix,CharSequence suffix)的第二个和第三个参数分别是拼接后的字符串的前缀和后缀。

public StringJoiner add(CharSequence newElement) {
    prepareBuilder().append(newElement);
    return this;
}

private StringBuilder prepareBuilder() {
    if (value != null) {
        value.append(delimiter);
    } else {
        value = new StringBuilder().append(prefix);
    }
    return value;
}

看到了一个熟悉的身影——StringBuilder ,没错,StringJoiner其实就是依赖StringBuilder实现的。 当我们发现StringJoiner其实是通过StringBuilder实现之后,我们大概就可以猜到,他的性能损耗应该和直接使用StringBuilder差不多!

为什么需要StringJoiner

在了解了StringJoiner的用法和原理后,可能很多读者就会产生一个疑问,明明已经有一个StringBuilder了, 为什么Java 8中还要定义一个StringJoiner呢?到底有什么好处呢?如果读者足够了解Java8的话,或许可以猜出个大概,这肯定和Stream有关。

当然,或许在Collector中直接使用StringBuilder似乎也可以实现类似的功能,只不过稍微麻烦一些。所以,Java 8中提供了StringJoiner来丰富Stream的用法。 而且StringJoiner也可以方便的增加前缀和后缀,比如我们希望得到的字符串是[zhuangxiaoyan,zhuangxiaoyanchuang,Java干货] 而不是zhuangxiaoyan,zhuangxiaoyanchuang,Java干货的话,StringJoiner的优势就更加明显了。

如果日常开发中中,需要进行字符串拼接,如何选择?

  1. 如果只是简单的字符串拼接,考虑直接使用"+"即可。
  2. 如果是在for循环中进行字符串拼接,考虑使用StringBuilder和StringBuffer。
  3. 如果是通过一个List进行字符串拼接,则考虑使用StringJoiner。

# valueOf和toString的区别

int i = 5;
String i1 = "" + i;
String i2 = String.valueOf(i);
String i3 = Integer.toString(i);

第三行和第四行没有任何区别,因为String.valueOf(i)也是调用Integer.toString(i)来实现的。 第二行代码其实是String i1 = (new StringBuilder()).append(i).toString();,首先创建一个StringBuilder对象,然后再调用append方法,再调用toString方法。

# switch对String的支持

Java 7中,switch的参数可以是String类型了,这对我们来说是一个很方便的改进。到目前为止switch支持这样几种数据类型:byte short int char String 。 但是,作为一个程序员我们不仅要知道他有多么好用,还要知道它是如何实现的,switch对整型的支持是怎么实现的呢?对字符型是怎么实现的呢? String类型呢?有一点Java开发经验的人这个时候都会猜测switch对String的支持是使用equals()方法和hashcode()方法。 那么到底是不是这两个方法呢?接下来我们就看一下,switch到底是如何实现的。

switch对整型支持的实现

public class switchDemoInt {
    public static void main(String[] args) {
        int a = 5;
        switch (a) {
        case 1:
            System.out.println(1);
            break;
        case 5:
            System.out.println(5);
            break;
        default:
            break;
        }
    }
}
//output 5

反编译后的代码如下:

public class switchDemoInt
{
    public switchDemoInt()
    {
    }
    public static void main(String args[])
    {
        int a = 5;
        switch(a)
        {
        case 1: // '\001'
            System.out.println(1);
            break;

        case 5: // '\005'
            System.out.println(5);
            break;
        }
    }
}

我们发现,反编译后的代码和之前的代码比较除了多了两行注释以外没有任何区别,那么我们就知道,switch对int的判断是直接比较整数的值。

switch对字符型支持的实现

public class switchDemoInt {
    public static void main(String[] args) {
        char a = 'b';
        switch (a) {
        case 'a':
            System.out.println('a');
            break;
        case 'b':
            System.out.println('b');
            break;
        default:
            break;
        }
    }
}

编译后的代码如下:

public class switchDemoChar
{
    public switchDemoChar()
    {
    }
    public static void main(String args[])
    {
        char a = 'b';
        switch(a)
        {
        case 97: // 'a'
            System.out.println('a');
            break;
        case 98: // 'b'
            System.out.println('b');
            break;
        }
  }
}

通过以上的代码作比较我们发现:对char类型进行比较的时候,实际上比较的是ascii码,编译器会把char型变量转换成对应的int型变量

switch对字符串支持的实现

public class switchDemoString {
    public static void main(String[] args) {
        String str = "world";
        switch (str) {
        case "hello":
            System.out.println("hello");
            break;
        case "world":
            System.out.println("world");
            break;
        default:
            break;
        }
    }
}

对代码进行反编译:

public class switchDemoString
{
    public switchDemoString()
    {
    }
    public static void main(String args[])
    {
        String str = "world";
        String s;
        switch((s = str).hashCode())
        {
        default:
            break;
        case 99162322:
            if(s.equals("hello"))
                System.out.println("hello");
            break;
        case 113318802:
            if(s.equals("world"))
                System.out.println("world");
            break;
        }
    }
}

看到这个代码,你知道原来字符串的switch是通过equals()和hashCode()方法来实现的。记住,switch中只能使用整型, 比如byte。short,char(ackii码是整型)以及int。还好hashCode()方法返回的是int,而不是long。通过这个很容易记住hashCode返回的是int这个事实。 仔细看下可以发现,进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。 因此它的性能是不如使用枚举进行switch或者使用纯整数常量,但这也不是很差。因为Java编译器只增加了一个equals方法, 如果你比较的是字符串字面量的话会非常快,比如”abc” ==”abc”。如果你把hashCode()方法的调用也考虑进来了, 那么还会再多一次的调用开销,因为字符串一旦创建了,它就会把哈希值缓存起来。因此如果这个switch语句是用在一个循环里的, 比如逐项处理某个值,或者游戏引擎循环地渲染屏幕,这里hashCode()方法的调用开销其实不会很大。

好,以上就是关于switch对整型、字符型、和字符串型的支持的实现方式,总结一下我们可以发现, 其实switch只支持一种数据类型,那就是整型,其他数据类型都是转换成整型之后再使用switch的。

# 字符串池

字符串大家一定都不陌生,他是我们非常常用的一个类。String作为一个Java类,可以通过以下两种方式创建一个字符串:

String str = "zhuangxiaoyan";
String str = new String("zhuangxiaoyan")

而第一种是我们比较常用的做法,这种形式叫做"字面量"。在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的。会单独开辟一块内存, 用于保存字符串常量,这个内存区域被叫做字符串常量池。当代码中出现双引号形式(字面量)创建字符串对象时,JVM 会先对这个字符串进行检查, 如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。 这种机制,就是字符串驻留或池化。

在JDK 7以前的版本中,字符串常量池是放在永久代中的。因为按照计划,JDK会在后续的版本中通过元空间来代替永久代,所以首先在JDK 7中,将字符串常量池先从永久代中移出,暂时放到了堆内存中。 在JDK 8中,彻底移除了永久代,使用元空间替代了永久代,于是字符串常量池再次从堆内存移动到永久代中

# Class常量池

在Java体系中,共用三种常量池。分别是字符串常量池、Class常量池和运行时常量池。

有了字节码,无论是哪种平台(如Windows、Linux等),只要安装了虚拟机,都可以直接运行字节码。同样,有了字节码,也解除了Java虚拟机和Java语言之间的耦合。 这话可能很多人不理解,Java虚拟机不就是运行Java语言的么?这种解耦指的是什么?其实,目前Java虚拟机已经可以支持很多除Java语言以外的语言了, 如Groovy、JRuby、Jython、Scala等。之所以可以支持,就是因为这些语言也可以被编译成字节码。而虚拟机并不关心字节码是有哪种语言编译而来的。

img_1.png

可以看到,上面的文件就是Class文件,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。要想能够读懂上面的字节码,需要了解Class类文件的结构, 读者可以看到,HelloWorld.class文件中的前八个字母是cafe babe,这就是Class文件的魔数(Java中的”魔数”)

我们需要知道的是,在Class文件的4个字节的魔数后面的分别是4个字节的Class文件的版本号(第5、6个字节是次版本号,第7、8个字节是主版本号, 我生成的Class文件的版本号是52,这时Java 8对应的版本。也就是说,这个版本的字节码,在JDK 1.8以下的版本中无法运行)在版本号后面的,就是Class常量池入口了。

Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外, 还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。

img.png

介绍完了什么是Class常量池以及如何查看常量池,那么接下来我们就要深入分析一下,Class常量池中都有哪些内容。 常量池中主要存放两大类常量:字面量(literal)符号引用(symbolic references)

说简单点,字面量就是指由字母、数字等构成的字符串或者数值。 字面量只可以右值出现,所谓右值是指等号右边的值,如:int a=123这里的a为左值,123为右值。在这个例子中123就是字面量。

符号引用是编译原理中的概念,是相对于直接引用来说的。主要包括了以下三类常量: * 类和接口的全限定名 * 字段的名称和描述符 * 方法的名称和描述符

可以明确的是,Class常量池是Class文件中的资源仓库,其中保存了各种常量。而这些常量都是开发者定义出来,需要在程序的运行期使用的。 Class是用来保存常量的一个媒介场所,并且是一个中间场所。在JVM真的运行时,需要把常量池中的常量加载到内存中。

# intern方法

在JVM中,为了减少相同的字符串的重复创建,为了达到节省内存的目的。会单独开辟一块内存,用于保存字符串常量,这个内存区域被叫做字符串常量池。

当代码中出现双引号形式(字面量)创建字符串对象时,JVM 会先对这个字符串进行检查,如果字符串常量池中存在相同内容的字符串对象的引用, 则将这个引用返回;否则,创建新的字符串对象,然后将这个引用放入字符串常量池,并返回该引用。

除了以上方式之外,还有一种可以在运行期将字符串内容放置到字符串常量池的办法,那就是使用intern。 intern的功能很简单:在每次赋值的时候使用 String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。

# 为什么ConcurrentHashMap中key不允许为null?

简单来说,就是为了避免在多线程环境下出现歧义问题。所谓歧义问题,就是如果key或者value为null,当我们通过get(key)获取对应的value的时候, 如果返回的结果是null我们没办法判断,它是put(k,v)的时候,value本身为null值,还是这个key本身就不存在。

比如在这样一种情况下,线程t1调用containsKey方法判断key是否存在,假设当前这个key不存在,本来应该返回false。 但是在T1线程返回之前,正好有一个T2线程插入了这个key,但是value为null。这就导致原本T1线程返回的结果有可能是true,有可能是false,取决于T1和T2线程的执行顺序。

img.png

这种现象我们可以认为是线程安全性问题,而ConcurrentHashMap又是一个线程安全的集合,所以自然就不允许key或者value为null。

而HashMap中是允许存null的,因为它不需要考虑到线程安全性问题。所以这个问题的核心本质还是ConcurrentHashMap这个并发安全性集合的特性。

# SimpleDateFormat是线程安全的吗?

SimpleDateFormat不是线程安全的,SimpleDateFormat类内部有一个Calendar对象引用,它用来储存和这个SimpleDateFormat相关的日期信息。 当我们把SimpleDateFormat作为多个线程的共享资源来使用的时候。意味着多个线程会共享SimpleDateFormat里面的Calendar引用, 多个线程对于同一个Calendar的操作,会出现数据脏读现象导致一些不可预料的错误。在实际应用中,我认为有4种方法可以解决这个问题。

  1. 第一种,把SimpleDateFormat定义成局部变量,每个线程调用的时候都创建一个新的实例。
  2. 第二种,使用ThreadLocal工具,把SimpleDateFormat变成线程私有的。
  3. 第三种,加同步锁,在同一时刻只允许一个线程操作SimpleDateFormat。
  4. 第四种,在Java8里面引入了一些线程安全的日期API,比如LocalDateTimer、DateTimeFormatter等。

# HashMap是怎么解决哈希冲突的?

要了解Hash冲突,那首先我们要先了解Hash算法和Hash表。

img.png

  1. Hash算法,就是把任意长度的输入,通过散列算法,变成固定长度的输出,这个输出结果是散列值。
  2. Hash表又叫做“散列表”,它是通过key直接访问在内存存储位置的数据结构,在具体实现上,我们通过hash函数把key映射到表中的某个位置,来获取这个位置的数据,从而加快查找速度。

所谓hash冲突,是由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,所以总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。

开放定址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从hash表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。ThreadLocal就用到了线性探测法来解决hash冲突的。

img.png

在hash表索引1的位置存了一个key=name,当再次添加key=hobby时,hash计算得到的索引也是1,这个就是hash冲突。而开放定址法,就是按顺序向前找到一个空闲的位置来存储冲突的key。

链式寻址法,这是一种非常常见的方法,简单理解就是把存在hash冲突的key,以单向链表的方式来存储,比如HashMap就是采用链式寻址法来实现的。存在冲突的key直接以单向链表的方式进行存储。

img.png

再hash法,就是当通过某个hash函数计算的key存在冲突时,再用另外一个hash函数对这个key做hash,一直运算直到不再产生冲突。这种方式会增加计算时间,性能影响较大。

建立公共溢出区, 就是把hash表分为基本表和溢出表两个部分,凡事存在冲突的元素,一律放入到溢出表中。

HashMap在JDK1.8版本中,通过链式寻址法+红黑树的方式来解决hash冲突问题,其中红黑树是为了优化Hash表链表过长导致时间复杂度增加的问题。当链表长度大于8并且hash表的容量大于64的时候,再向链表中添加元素就会触发转化。