1. JAVA基础
1.1 Java基本类型有哪些?它们分别占用多少字节?
Java中的基本类型包括:
- byte(1字节)
- short(2字节)
- char(2字节)
- int(4字节)
- float(4字节)
- double(8字节)
- long(8字节)
- boolean(未定,和具体Java虚拟机实现有关)
关于boolean的占用大小,事实上,1 bit是计算机存储的最小单位,用它来表示boolean的语义也完全足够。但是考虑到计算机处理数据的最小单位是1byte,因此实际的存储空间至少为1byte。而在《Java虚拟机规范》一书中描述,对于当下32位的处理器(CPU)来说,一次处理数据是32位,32 位 CPU 使用 4 个字节是最为节省的,哪怕你是1 bit信息也需要占用 4 个字节。因为 CPU 寻址系统只能 32 位 32 位地寻址,具有高效存取的特点。
1.2 什么是Java装箱和拆箱?其原理?
Java中每一种基本数据类型都对应着一种包装器类型。装箱是指Java能够自动将基本数据类型自动转化成包装器类型。拆箱是指Java能够自动将包装器类型转化为基本数据类型。例如:
Integer i = 10; //装箱 int j = i; //拆箱
装箱是自动调用了Integer.valueOf(int i)
方法。拆箱则是调用了Integer.intValue(Integer i)
方法。
阅读下面一段代码:
public class Main { public static void main(String[] args) { Integer i1 = 100; Integer i2 = 100; Integer i3 = 200; Integer i4 = 200; System.out.println(i1==i2); System.out.println(i3==i4); } }
其输出值为:
true false
参考Integer.valueOf(int i)
的实现:
public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }
可以看到当i的范围在[-128, 127]时,Integer直接指向IntegerCache中的对象。否则,创建一个新的Integer对象。
public static Double valueOf(double d) { return new Double(d); }
因此装箱后的Double对象的比较总是返回false。
下面是Boolean.valueOf(boolean b)
的实现:
而Double.valueOf(double d)的实现如下:
public static Boolean valueOf(boolean b) { return (b ? TRUE : FALSE); }
因此装箱后的Boolean对象的比较总是返回true。
1.3 String,StringBuffer和StringBuilder的区别
String类是不可变类,任何对于String对象的改变都会生成一个新的对象。StringBuffer和StringBuilder都是可变类,任何修改都会造成其内部的char数组的改变。StringBuffer是线程安全的,而StringBuilder则是线程不安全的。
1.4 Java类加载机制
- Java类加载机制
1.5 LocalThread
- ThreadLocal的实现
1.6 引用类型
从JDK 1.2版本开始,把对象的引用分为4种级别,从而使程序能更加灵活地控制对象的生命周期。这4种级别由高到低依次为:强引用、软引用、弱引用和虚引用。
- 强引用(StrongReference):强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。
- 软引用(SoftReference):如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
- 弱引用(WeakReference):只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
- 虚引用(PhantomReference):虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
1.7 hashcode的设计
Java String类的hashcode方法实现如下:
public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; }
关于hashcode的计算为何选取质数31,下面是一个解释:https://www.zhihu.com/question/24381016/answer/160483854
因此,一个常见的重写对象的hashcode的方法如下:
A、初始化一个整形变量,为此变量赋予一个非零的常数值,比如int result = 17;
B、选取equals方法中用于比较的所有域,然后针对每个域的属性进行计算:
- 如果是boolean值,则计算 f ? 1:0
- 如果是byte\char\short\int,则计算 (int)f
- 如果是long值,则计算
(int)(f ^ (f >>> 32))
- 如果是float值,则计算
Float.floatToIntBits(f)
- 如果是double值,则计算
Double.doubleToLongBits(f)
,然后返回的结果是long,再用计算long值hashcode的方法去处理long,得到hashcode - 如果是对象应用,如果equals方法中采取递归调用的比较方式,那么hashCode中同样采取递归调用hashCode的方式。否则需要为这个域计算一个范式,比如当这个域的值为null的时候,那么hashCode 值为0
- 如果是数组,那么需要为每个元素当做单独的域来处理。
java.util.Arrays.hashCode
方法包含了8种基本类型数组和引用数组的hashCode计算,算法同上。
C、最后,把每个域的散列码合并到对象的哈希码中。
下面是一个例子:
public class Person { private String name; private int age; private boolean gender; 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; } public boolean isGender() { return gender; } public void setGender(boolean gender) { this.gender = gender; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Person person = (Person) o; return age == person.age && gender == person.gender && Objects.equals(name, person.name); } @Override public int hashCode() { int hash = 17; hash = hash * 31 + getName().hashCode(); hash = hash * 31 + getAge(); return hash; }
1.8 重写和重载
重写(Override)是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。
而重载(overloading) 是指在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。
1.9 Happens-Before规则
- 重排序,可见性,内存屏障和Happens-Before
1.10 NIO
2. Java集合类
2.1 ArrayList
ArrayList本质上是一个动态数组,它是线程不安全的,读写时间为O(1)。
它的内部数据结构主要为:
//默认初始数组大小:10 private static final int DEFAULT_CAPACITY = 10; //用于存储数据的数组 transient Object[] elementData; //记录size的变量 private int size;
transient表示elementData数组不参与序列化。然而在
writeObject
方法中,elementData数组又参与了序列化过程,这是为什么呢?因为elementData数组的length往往并不等于ArrayList.size,因此直接使用默认的序列化过程,会将elementData数组中的额外空间也序列化到输出流中。因此采用自定义writeObject
方法,手动从elementData数组中取出size个元素进行序列化。具体实现如下:
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<size; i++) { s.writeObject(elementData[i]); } if (modCount != expectedModCount) { throw new ConcurrentModificationException();
} }
add(E element), addAll(Collection<E> elements)方法总结:1. 首先判断是否越界,是否需要扩容。2. 如果需要扩容,默认扩容当前大小的1.5倍。如果还不够,则直接扩容为所需要的最小size。然后复制数组。3. 修改modCount变量。
public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e;//在数组末尾追加一个元素,并修改size return true; }
private static final int DEFAULT_CAPACITY = 10;//默认扩容容量 10 private void ensureCapacityInternal(int minCapacity) { //利用 == 可以判断数组是否是用默认构造函数初始化的 if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount++;//如果确定要扩容,会修改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); elementData = Arrays.copyOf(elementData, newCapacity);//拷贝,扩容,构建一个新数组 }
E Remove(int index)方法总结:1. 首先判断是否越界 2. 修改modCount,读出要删除的值,然后将[index + 1, size]内的所有元素复制到[index, size - 1]位置 3. 将下标为size-1的元素置为null 4. 返回被删除的元素
public E remove(int index) { rangeCheck(index);//判断是否越界 modCount++;//修改modeCount 因为结构改变了 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; }
modCount变量用于记录List被修改的次数,因此在使用Iterator遍历时调用List.remove(E element)时会抛出java.util.ConcurrentModificationException。原因在于调用List.remove(E element)后会修改modCount变量,而Iterator遍历时会判断当前modCount值是否和expectedModCount(第一次遍历时的modCount)相同,如果不相同,则抛出java.util.ConcurrentModificationException。
增删改查中, add(E element)会导致扩容,因此会修改modCount,remove(E element)也会修改数组,因此也会修改modCount。set(int index, E element)和get(int index)则不会修改modCount。
Vector内部也通过数组实现,它和ArrayList别在于Vector在API上都加了synchronized,所以它是线程安全的,并且Vector扩容时,是翻倍size,而ArrayList则是扩容50%。
2.2 LinkedList
LinkedList是基于双向链表实现的有序序列,它可以在任何位置进行高效的插入和删除。
它的内部数据结构如下:
// 链表长度 transient int size = 0; // 链表头节点 transient Node<E> first; // 链表尾节点 transient Node<E> last; 其中链表节点的数据结构如下:
private static class Node<E> { E item; // 此节点包含的数据 Node<E> next; // 下一个节点的引用 Node<E> prev; // 前一个节点的引用 Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } }
下面是boolean add(E element)
方法的实现:
public boolean add(E e) { linkLast(e); return true; } void linkLast(E e) { final Node<E> l = last; // 获取尾部元素 final Node<E> newNode = new Node<>(l, e, null); last = newNode; // 更新尾部节点为需要插入的节点 if (l == null) first = newNode; else l.next = newNode; size++; modCount++; }
boolean add(E element)
方法总结:1. 创建一个新节点,将last节点设置为其前节点,后节点为null 2. 如果last节点为null,将first节点设置为当前节点 3. 将当前节点设置为last节点,并将当前节点设置为原有last节点的后节点 4. 链表长度加1
下面是E get(int index)
方法的实现:
public E get(int index) { checkElementIndex(index); return node(index).item; } Node<E> node(int index) { if (index < (size >> 1)) { Node<E> x = first; for (int i = 0; i < index; i++) x = x.next; return x; } else { Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } }
由于链表不存在下标索引,因此要找出指定位置的元素,就需要遍历整个链表。这儿使用了一种加速手段,当index处于链表的前半段时,那么就从首节点开始遍历,反之则从尾节点遍历。
下面是E remove(int index)
方法的实现:
public E remove(int index) { // 移除元素索引的合理性检查 checkElementIndex(index); // 将节点删除 return unlink(node(index)); } E unlink(Node<E> x) { final E element = x.item; // 得到指定节点的值 final Node<E> next = x.next; // 得到指定节点的后继节点 final Node<E> prev = x.prev; // 得到指定节点的前继节点 // 如果prev为null表示删除是头节点,否则就不是头节点 if (prev == null) { first = next; } else { prev.next = next; x.prev = null; // 置空需删除的指定节点的前置节点(null) } // 如果next为null,则表示删除的是尾部节点,否则就不是尾部节点 if (next == null) { last = prev; } else { next.prev = prev; x.next = null; // 置空需删除的指定节点的后置节点 } // 置空需删除的指定节点的值 x.item = null; size--; // 数量减1 modCount++; return element; }
LinkedList与ArrayList在性能上各有优缺点,都有各自适用的地方,总结如下:
- ArrayList是实现了基于动态数组的数据结构,LinkedList基于链表的数据结构。
- LinkedList不支持高效的随机元素访问。
- ArrayList的空间浪费主要体现在在list列表的结尾预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗相当的空间,就存储密度来说,ArrayList是优于LinkedList的。
- LinkedList能够或得更好的插入和删除表现,而ArrayList的随机读取表现更好。
2.3 HashMap
HashMap是一种底层为数组的哈希表实现。它是线程不安全的,允许key为null,value为null,遍历时无序。
它的内部数据结构如下:
transient Node<K,V>[] table; //哈希桶,存放链表。初始大小为16 transient int size; // HashMap中实际存在的Node数量 final float loadFactor; //加载因子,用于计算哈希表元素数量的阈值。默认值为0.75。 int threshold; //哈希表内元素数量的阈值,当元素数量超过阈值时,
会触发扩容。threshold = 哈希桶.length * loadFactor
threshold是HashMap在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。
下面是哈希表数据类的定义,它的本质是一个单向链表:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; //用来定位数组索引位置 final K key; V value; Node<K,V> next; //链表的下一个node }
HashMap确定某个key所在哈希桶的位置方法如下:
//计算key的hash值 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } //根据hash值确定哈希桶位置 static int indexFor(int h, int length) { return h & (length-1); }
这里的Hash算法本质上就是三步:
(1) 取key的hashCode值:h = key.hashCode()
(2) 高位参与运算:h ^ (h >>> 16)
(3) 取模运算获得数组下标:h & (length-1)
首先我们先看HashMap如何根据一个hash值定位数组下标,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
而为什么不直接让key.hashCode()作为hash值呢?因为计算h & (table.length -1)
时,由于length为2的n次方,因此table.length -1高位全部为0,以16为例,这样计算出来的index完全只保留hash值的低4位,所有hash的高位部分都丢失了。因此通过计算(h = key.hashCode()) ^ (h >>> 16)
,将自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性,从而尽可能的减少碰撞。这也被称为扰动函数。
更多关于hash函数的解释,请参考:https://www.zhihu.com/question/20733617
关于put函数的流程图如下:
put(K key, V value)函数流程图
扩容机制:
HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,然后对所有的元素进行rehash。
一个最简单的rehash思路是遍历所有的元素,一个一个重新计算hash值和数组下标,然后进行分配。然而Java8对rehash进行了优化。请看下面一个例子:Hash桶初始大小为4,现在有两个hash值为1和5的元素,它们都被分配到了table[1]中。计算下标的过程如下:
1:(0000 0001)&(0000 0011)= 1 5:(0000 0101)&(0000 0011)= 1
现在HashMap进行resize,那么哈希桶的大小变成了8,那么它们计算下标的过程如下:
1:(0000 0001)&(0000 0111)= 1 5:(0000 0101)&(0000 0111)= 5
我们可以看到,事实上由于HashMap进行resize,是原有size * 2,因此元素下标位置只可能产生两种变化,即元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置(即加上原有桶大小)。
因此,可以产生如下伪代码:
if (hash & oldCapacity == 0) { //下标不变 } else { //下标 = 原有下标 + oldCapacity }
更多关于resize的内容,请参考:https://blog.csdn.net/login_sonata/article/details/76598675
2.4 LinkedHashMap
LinkedHashMap是HashMap的子类,它在HashMap的基础上维护了一个双向队列,因此能够按照顺序遍历Map。它的内部数据结构如下:
public class LinkedHashMap<K, V> extends HashMap<K, V> { //链表头节点 transient LinkedHashMap.Entry<K,V> head; //链表尾节点 transient LinkedHashMap.Entry<K,V> tail; //遍历顺序,false表示按照插入顺序,true表示按照最近最少使用顺序 final boolean accessOrder; }
下面是LinkedHashMap节点的数据结构:
static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; //前继节点和后继节点 Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }
可以看到LinkedHashMap节点本质上是一个双向链表。
LinkedHashMap在HashMap的基础上重写了其钩子方法,其中主要的钩子方法如下:
// Callbacks to allow LinkedHashMap post-actions void afterNodeAccess(Node<K,V> p) { } void afterNodeInsertion(boolean evict) { } void afterNodeRemoval(Node<K,V> p) { } public V get(Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null) return null; //在取值后对参数accessOrder进行判断,如果为true,执行afterNodeAccess if (accessOrder) afterNodeAccess(e); return e.value; }
afterNodeAccess(Node<K,V> p)
方法用于将当前节点移动到双向队列尾部,因此accessOrder = true
时,通过afterNodeAccess(Node<K,V> p)
方法,就实现了LRU cache。
afterNodeRemoval(Node<K,V> e)
则是由remove方法调用的,它主要用于更新LinkedHashMap内部维护的双向队列。
afterNodeInsertion(boolean evict)
方法则是由put方法调用的,它的实现如下:
void afterNodeInsertion(boolean evict) { LinkedHashMap.Entry<K,V> first; if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; removeNode(hash(key), key, null, false, true); } } protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return false; }
我们可以看到,afterNodeInsertion(boolean evict)
方法用于删除队首元素。默认情况下removeEldestEntry(Map.Entry<K,V> eldest)
方法返回false,因此afterNodeInsertion(boolean evict)
方法不会删除任何元素。
我们可以通过重写removeEldestEntry(Map.Entry<K,V> eldest)
方法来实现一个LRU Cache:
class LRUCache extends LinkedHashMap<Integer, Integer> { private int capacity; public LRUCache(int capacity) { super(capacity, 1, true); this.capacity = capacity; } public int get(int key) { if (containsKey(key)) { return get(key); } return -1; } public void put(int key, int value) { put(key, value); } @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > capacity; } }
这样,每当这个数据量超出这个LRU Cache的capacity时,LinkedHashMap会删除队首元素。由于super(capacity, 1, true)
将accessOrder设置为了true,因此队首元素就是最近最少用元素,从而实现了一个简易的LRU Cache。