JAVA研发工程师面试重点(语法基础篇2)
- 一、JAVA基础知识(2)
- 3、集合类:
- 3.1、HashMap:
- 3.2、HashMap面试题
- 3.2.1、HashMap的底层原理
- 3.2.2和hashTable有啥区别:
- B、HashMap的特性
- 3.2.4、hashMap中put是如何实现
- 3.2.5、hashMap中什么时候需要进行扩容,扩容resize()又是如何实现的
- 3.2.6、hashMap中get实现
- 3.2.7、HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
- 3.2.9、为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样
- 3.2.10、当两个对象的hashCode相等时,两个键的hashcode相同,你如何获取值对象
- 3.2.11、HashMap的大小超过了负载因子(load factor)定义的容量,怎么办
- 3.2.12、传统hashMap的缺点(为什么引入红黑树?)
- 3.2.13、使用HashMap时一般使用什么类型的元素作为Key
- 3.2.14、你用过HashMap吗?” “什么是HashMap?你为什么用到它?
- 3.3、ConcurrentHashMap
- 3.4、集合类的线程安全及比较:
一、JAVA基础知识(2)
3、集合类:
3.1、HashMap:
1、定义
HashMap也是我们使用非常多的Collection,它是基于哈希表的 Map 接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们总是可以通过key快速地存、取value。
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map
2、哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法
而HashMap即是采用了链地址法,也就是数组+链表的方式。
3、数据结构
HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
4、HashMap的实现原理
HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。(其实所谓Map其实就是保存了两个对象之间的映射关系的一种集合)
//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
3.2、HashMap面试题
3.2.1、HashMap的底层原理
基于hashing的原理,jdk8后采用数组+链表+红黑树的数据结构。我们通过put和get存储和获取对象。当我们给put()方法传递键和值时,先对键做一个hashCode()的计算来得到它在bucket数组中的位置来存储Entry对象。当获取对象时,通过get获取到bucket的位置,再通过键对象的equals()方法找到正确的键值对,然后在返回值对象。
3.2.2和hashTable有啥区别:
相同点:都是存储key-value键值对的
不同点:
- HashMap允许Key-value为null,hashTable不允许;
- hashMap没有考虑同步,是线程不安全的。hashTable是线程安全的,给api套上了一层synchronized修饰;
- HashMap继承于AbstractMap类,hashTable继承与Dictionary类。
- 迭代器(Iterator)。HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException。
- ****容量的初始值和增加方式都不一样:HashMap默认的容量大小是16;增加容量时,每次将容量变为"原始容量x2"。Hashtable默认的容量大小是11;增加容量时,每次将容量变为"原始容量x2 + 1";
- 添加key-value时的hash值算法不同:**HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()。
B、HashMap的特性
- HashMap存储键值对实现快速存取,允许为null。key值不可重复,若key值重复则覆盖。
- 非同步,线程不安全。
- 底层是hash表,不保证有序(比如插入的顺序)
3.2.4、hashMap中put是如何实现
- 计算关于key的hashcode值(与Key.hashCode的高16位做异或运算)
- 如果散列表为空时,调用resize()初始化散列表
- 如果没有发生碰撞,直接添加元素到散列表中去
- 如果发生了碰撞(hashCode值相同),进行三种判断
4.1、若key地址相同或者equals后内容相同,则替换旧值
4.2、 如果是红黑树结构,就调用树的插入方法
4.3、链表结构,循环遍历直到链表中某个节点为空,尾插法进行插入,插入之后判断链表个数是否到达变成红黑树的阙值8;也可以遍历到有节点与插入元素的哈希值和内容相同,进行覆盖。 - 如果桶满了大于阀值,则resize进行扩容
3.2.5、hashMap中什么时候需要进行扩容,扩容resize()又是如何实现的
调用场景:
- 初始化数组table
- 当数组table的size达到阙值时即++size > load factor * capacity 时,也是在putVal函数中
概括的讲:扩容需要重新分配一个新数组,新数组是老数组的2倍长,然后遍历整个老结构,把所有的元素挨个重新hash分配到新结构中去。
可见底层数据结构用到了数组,到最后会因为容量问题都需要进行扩容操作
- 通过判断旧数组的容量是否大于0来判断数组是否初始化过
否:进行初始化 - 判断是否调用无参构造器
- 是:使用默认的大小和阙值
- 否:使用构造函数中初始化的容量,当然这个容量是经过tableSizefor计算后的2的次幂数
是,进行扩容,扩容成两倍(小于最大值的情况下),之后在进行将元素重新进行与运算复制到新的散列表中
3.2.6、hashMap中get实现
对key的hashCode进行hashing,与运算计算下标获取bucket位置,如果在桶的首位上就可以找到就直接返回,否则在树中找或者链表中遍历找,如果有hash冲突,则利用equals方法去遍历链表查找节点。
3.2.7、HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
对key的hashCode做hash操作,与高16位做异或运算
还有平方取中法,除留余数法,伪随机数法
3.2.9、为什么是16?为什么必须是2的幂?如果输入值不是2的幂比如10会怎么样
- 为了数据的均匀分布,减少哈希碰撞。因为确定数组位置是用的位运算,若数据不是2的次幂则会增加哈希碰撞的次数和浪费数组空间。(PS:其实若不考虑效率,求余也可以就不用位运算了也不用长度必需为2的幂次)
- 输入数据若不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字
3.2.10、当两个对象的hashCode相等时,两个键的hashcode相同,你如何获取值对象
- 会产生哈希碰撞,若key值相同则替换旧值,不然链接到链表后面,链表长度超过阙值8就转为红黑树存储
- HashCode相同,通过equals比较内容获取值对象
3.2.11、HashMap的大小超过了负载因子(load factor)定义的容量,怎么办
超过阙值会进行扩容操作,概括的讲就是扩容后的数组大小是原数组的2倍,将原来的元素重新hashing放入到新的散列表中去。
3.2.12、传统hashMap的缺点(为什么引入红黑树?)
JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。
3.2.13、使用HashMap时一般使用什么类型的元素作为Key
选择Integer,String这种不可变的类型,像对String的一切操作都是新建一个String对象,对新的对象进行拼接分割等,这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的,
3.2.14、你用过HashMap吗?” “什么是HashMap?你为什么用到它?
HashMap实现了Map接口,Map接口对键值对进行映射。Map中不允许重复的键。Map接口有两个基本的实现,HashMap和TreeMap。TreeMap保存了对象的排列次序,而HashMap则不能。HashMap存储的是键值对,允许键和值为null。HashMap是非synchronized的,但collection框架提供方法能保证HashMap synchronized,这样多个线程同时访问HashMap时,能保证只有一个线程更改Map
3.3、ConcurrentHashMap
3.3.1、分段锁
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁,在ConcurrentHashMap内部,段数组是final的,并且其成员变量实际上也是final的,但是,仅仅是将数组声明为final的并不保证数组成员也是final的,这需要实现上的保证。这可以确保不会出现死锁,因为获得锁的顺序是固定的。
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
HashMap存在的问题:
HashMap线程不安全
因为多线程环境下,使用Hashmap进行put操作可能会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。
Hashtable线程安全但效率低下
Hashtable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下Hashtable的效率非常低下。因为当一个线程访问Hashtable的同步方法时,其他线程访问Hashtable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。
3.3.2、和HashTable的区别:
ConcurrentHashMap 类(是 Java并发包 java.util.concurrent 中提供的一个线程安全且高效的 HashMap 实现)。
HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁);
而针对 ConcurrentHashMap,在 JDK 1.7 中采用 分段锁的方式;JDK 1.8 中直接采用了CAS(无锁算法)+ synchronized。另外,HashMap 的键值对允许有null,但是ConCurrentHashMap 都不允许。
ConcurrentHashMap 是一个并发散列映射表,它允许完全并发的读取,并且支持给定数量的并发更新。
而HashTable和同步包装器包装的 HashMap,使用一个全局的锁来同步不同线程间的并发访问,同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器,这虽然保证多线程间的安全并发访问,但同时也导致对容器的访问变成串行化的了。
3.3.3、总结:
Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,效率较低。
ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。
3.4、集合类的线程安全及比较:
3.4.1、线程概念
-
**线程安全:**就是当多线程访问时,采用了加锁的机制;即当一个线程访问该类的某个数据时,会对这个数据进行保护,其他线程不能对其访问,直到该线程读取完之后,其他线程才可以使用。防止出现数据不一致或者数据被污染的情况。
-
**线程不安全:**就是不提供数据访问时的数据保护,多个线程能够同时操作某个数据,从而出现数据不一致或者数据污染的情况。
-
对于线程不安全的问题,一般会使用synchronized关键字加锁同步控制。
-
线程安全工作原理: jvm中有一个main memory对象,每一个线程也有自己的working memory,一个线程对于一个变量variable进行操作的时候, 都需要在自己的working memory里创建一个copy,操作完之后再写入main memory。 当多个线程操作同一个变量variable,就可能出现不可预知的结果。
而用synchronized的关键是建立一个监控monitor,这个monitor可以是要修改的变量,也可以是其他自己认为合适的对象(方法),然后通过给这个monitor加锁来实现线程安全,每个线程在获得这个锁之后,要执行完加载load到working memory 到 use && 指派assign 到 存储store 再到 main memory的过程。才会释放它得到的锁。这样就实现了所谓的线程安全。
3.4.2、线程安全集合对象
- Vector 线程安全:
- HashTable 线程安全:
- StringBuffer 线程安全
3.4.3、非线程安全的集合对象
- ArrayList :
- LinkedList:
- HashMap:
- HashSet:
- TreeMap:
- TreeSet:
- StringBulider:
3.4.4、集合比较
1、Vector、ArrayList、LinkedList:
-
Vector:
Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问ArrayList慢。
-
ArrayList:
- 当操作是在一列数据的后面添加数据而不是在前面或者中间,并需要随机地访问其中的元素时,使用ArrayList性能比较好。
- ArrayList是最常用的List实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要讲已经有数组的数据复制到新的存储空间中。当从ArrayList的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。
- LinkedList:
- 当对一列数据的前面或者中间执行添加或者删除操作时,并且按照顺序访问其中的元素时,要使用LinkedList。
- LinkedList是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另外,他还提供了List接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。
Vector和ArrayList在使用上非常相似,都可以用来表示一组数量可变的对象应用的集合,并且可以随机的访问其中的元素。
Vector与ArrayList比较:
-
性能上
ArrayList底层数据结构是数组,适合随机查找和遍历,不适合插入和删除,线程不安全,效率高。LinkedList底层数据结构是链表, 适合数据的动态插入和删除,随机访问和遍历速度比较慢,线程不安全,效率高。 -
同步性
Vectors是可同步的,是线程安全的。ArrayList是不可同步的,不是线程安全的。所以,一般单线程推荐用ArrayList,多线程中则用Vector -
数据增长
往一个ArrayList或者Vector里插入一个元素时,如果内部数组空间不够,ArrayList或Vector会扩展它的大小。Vector在默认情况下增长一倍的大小,而ArrayList增加50%的大小。
2.HashTable、HashMap、HashSet:
HashTable和HashMap采用的存储机制是一样的,不同的是:
- HashMap
- 采用数组方式存储key-value构成的Entry对象,无容量限制;
- 基于key hash查找Entry对象存放到数组的位置,对于hash冲突采用链表的方式去解决;
- 在插入元素时,可能会扩大数组的容量,在扩大容量时须要重新计算hash,并复制对象到新的数组中;
- 是非线程安全的;
- 遍历使用的是Iterator迭代器;
- HashTable
- 是线程安全的;
- 无论是key还是value都不允许有null值的存在;在HashTable中调用Put方法时,如果key为null,直接抛出NullPointerException异常;
- 遍历使用的是Enumeration列举;
- HashSet
- 基于HashMap实现,无容量限制;
- 是非线程安全的;
- 不保证数据的有序;
3、TreeSet、TreeMap:
TreeSet和TreeMap都是完全基于Map来实现的,并且都不支持get(index)来获取指定位置的元素,需要遍历来获取。另外,TreeSet还提供了一些排序方面的支持,例如传入Comparator实现、descendingSet以及descendingIterator等。
- TreeSet:
- 基于TreeMap实现的,支持排序;
- 是非线程安全的;
- TreeMap
- 典型的基于红黑树的Map实现,因此它要求一定要有key比较的方法,要么传入Comparator比较器实现,要么key对象实现Comparator接口;
- 是非线程安全的;
Collection有两个子接口:List和Set,二者主要区别在于:list数据有序存放、可重复;set中数据无序存放,不可重复。