0
点赞
收藏
分享

微信扫一扫

《Java面试自救指南》(专题四)Java基础

凛冬已至夏日未远 04-07 14:30 阅读 2
java面试

文章目录

有序集合有哪些?线程安全的集合有哪些?

  1. 有序集合

在这里插入图片描述

CollectionMap接口常用的实现类:

父接口子接口实现类
CollectionListVector
CollectionListArrayList
CollectionListLinkedList
CollectionSetHashSet (HashMap实现)
CollectionSetHashSet<–LinkedHashSet
CollectionSetSortedSet<–TreeSet(SortedMap实现)
MapAbstractMapHashMap
Map-HashTable
MapSortedMapTreeMap
Map-ConcurrentHashMap/LinkedHashMap

(1)ArrayList与LinkedList

  • 底层的数据结构:ArrayList的底层数据结构的数组;LinkedList的底层数据结构是双向链表
  • 插入和删除元素是否受元素位置影响:ArrayList因为底层是数组,插入和删除元素的时间复杂度受到元素位置影响
  • LinkedList底层依赖的是双向链表,如果采用add(E e)方法时,几乎不受影响,但是调用add(int index,E e)时,插入或删除指定位置时元素时,会受到影响;
  • 是否支持快速访问,ArrayList底层依赖的是数组,可以根据索引快速访问指定元素;LinkedList不可以;
  • 内存空间的占用情况:ArrayList空间消耗主要是结尾预留出一定空间;LinkedList空间消耗主要是体现在每一个元素都比ArrayList多(需要存储直接后继和前驱以及数据)

(2)Vector & Hashtable

VectorArrayList类似,是长度可变的数组。

ArrayList不同的是,Vector是线程安全的,利用synchronized锁住方法。

ArrayList初始化容量为10,然后在添加元素超过10后,会以1.5倍的容量进行扩容;而Vector的扩容倍数为2倍。

HashTableHashMap类似,不同点是HashTable是线程安全的,表级锁。

(3)ArrayList和HashMap

ArrayListHashMap都不是线程安全的,Collections工具类中提供了相应的包装方法把它们包装成线程安全的集合。

List<E> synArrayList = Collections.synchronizedList(new ArrayList<E>());

Set<E> synHashSet = Collections.synchronizedSet(new HashSet<E>());

Map<K,V> synHashMap = Collections.synchronizedMap(new HashMap<K,V>());
  • HashMap:数组+链表;数组+链表/红黑树;

  • TreeMap:红黑树

  • LinkedHashMap:链表+HashMap

  1. java.util.concurrent(juc)包中的集合

(1)ConcurrentHashMapHashTable

两者都是线程安全的集合,主要是加锁粒度上的不同。HashTable的加锁是给每个方法加上synchronized关键字,这样锁住的是整个Table对象。

ConcurrentHashMap是更细粒度的加锁。

在JDK1.8之前,ConcurrentHashMap加的是分段锁,也就是Segment锁,每个Segment含有整个table的一部分,这样不同分段之间的并发操作就互不影响。

JDK1.8对此做了进一步的改进,它取消了Segment字段,直接在table元素(桶)上加锁,实现对每一个桶内全部entry进行加锁,进一步减小了并发冲突的概率。(具体原理之后会详细论述)

(2)CopyOnWriteArrayListCopyOnWriteArraySet

两者是加了写锁的ArrayList和ArraySet,锁住的是整个对象,但读操作可以并发执行,基本的思路是基于读写分离的,当需要修改数据时,获得锁之后,拷贝原数组并在复制得到的新数组上进行增删改,完成之后改变数组引用,而此时的并发读不受影响,访问的依然是旧数组的旧数据。

以下面CopyOnWriteArrayList类中的add() 方法为例:

 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();
    }
    }
  1. HashTable和ConcurrentHashMap

(1)HashTable

  • 底层数组+链表实现,无论key还是value都不能为null
  • 线程安全,实现线程安全的方式是,在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
  • 初始size为11,扩容:newsize = olesize*2+1
  • 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length

(2)HashMap

  • 底层数组+链表/红黑树实现,可以存储null键和null值,线程不安全
  • 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
  • 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
  • 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
  • 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
  • 计算index方法:index = hash & (tab.length – 1)

为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%(加载因子)时,即会触发扩容。因此,如果预估容量是100,需要设定100/0.75=134的数组大小,避免无谓的扩容操作。

当hash表中的负载因子达到指定的“负载极限”时,hash表会自动成倍地增加容量(桶的数量),并将原有的对象重新分配,放入新的桶内,这称为rehashing。

(3)HashMap与Hashtable的区别

  • Hashtable和HashMap都实现了Map接口,但是Hashtable的实现是基于Dictionary抽象类的。Java5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
  • HashMap基于哈希思想,实现对数据的读写。将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hash,然后找到bucket位置来存储值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。
  • HashMap使用链表来解决碰撞问题,当发生碰撞时,对象将会储存在链表的下一个节点中。HashMap在每个链表节点中储存键值对对象。当两个不同的键对象的hashcode相同时,它们会储存在同一个bucket位置的链表中,可通过键对象的equals()方法来找到键值对。
  • HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。

(4)ConcurrentHashMap与HashTable

jdk7底层采用分段Segment的数组+链表实现,线程安全;jdk8基于cas和synchronized。

ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。

Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;

ConcurrentHashMap比HashMap多出了一个类Segment,而Segment是一个可重入锁。通过把整个Map分为N个Segment,一次锁住一个Segment。可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)

扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容

HashMap底层原理

搞清楚几个问题:jdk1.8,相比于1.7有什么改进?为什么长度大于8转换成红黑树?这个8怎么来的?

在JDK1.6、1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。

而JDK1.8中,HashMap采用位桶+链表/红黑树。链表转换为红黑树首先需要判断第一个条件:当前桶是否有8个值,如果达到了,会调用treeifyBin()这个方法,这个方法的第一步就是判断当前的map集合总数量是否达到了64个,如果没有达到的话,直接进行扩容。所以,只有map总数达到64个,并且单个桶数量达到8个时,链表才转换为红黑树。

根据前文提到,hashmap的加载因子默认为0.75,那么为什么需要使用加载因子,为什么需要扩容呢?

填充比很大,说明利用的空间很多,如果一直不进行扩容的话,链表就会越来越长,造成查找效率很低;扩容之后,将原来链表数组的每一个链表分成奇偶两个子链表分别挂在新链表数组的散列位置,这样就减少了每个链表的长度,提高查找效率。

HashMap本来是以空间换时间,所以填充比没必要太大。但是填充比太小又会导致空间浪费。如果关注内存,填充比可以稍大,如果主要关注查找性能,填充比可以稍小。填充比就是加载因子。

  1. HashMap源码

(1)get(key)方法

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab;//Entry对象数组
	Node<K,V> first,e; //在tab数组中经过散列的第一个位置
	int n;
	K k;
	//找到插入的第一个Node,方法是hash值和n-1相与,tab[(n - 1) & hash]
	//也就是说在一条链上的hash值相同的
    if ((tab = table) != null &&
     (n = tab.length) > 0 &&
     (first = tab[(n - 1) & hash]) != null) {
/*检查第一个Node是不是要找的Node*/
         if (first.hash == hash && // always check first node
             ((k = first.key) == key || (key != null && key.equals(k))))
             //判断条件是hash值要相同,key值也要相同(== 或 equals)
             return first;
		 //检查first后面的node
         if ((e = first.next) != null) {
             if (first instanceof TreeNode)
                 return ((TreeNode<K,V>)first).getTreeNode(hash, key);
             do {	
                 //遍历后面的链表,找到key值和hash值都相同的Node
                 if (e.hash == hash &&
                     ((k = e.key) == key || (key != null &&
                      key.equals(k))))
                     return e;
             } while ((e = e.next) != null);
         }
     }
     return null;
 }

执行方法时,先获取key的hash值,计算hash&(n-1)得到在链表数组中的位置first=tab[hash&(n-1)],先判断first的key是否与参数key相等,不等就遍历后面的链表找到相同的key值返回对应的Value值即可。注意这里判断相等涉及到hashcode()和equals()方法的调用。

思考一下,用int index = (tab.length - 1) & hash来计算查找/插入的元素在数组中的索引位置。这样做有什么妙处?

  • 保证不会发生数组越界

首先我们要知道,在HashMap和ConcurrentHashMap中,数组的长度按规定一定是2的幂。因此,数组的长度的二进制形式是:10000…000, 1后面有一堆0。那么tab.length - 1 的二进制形式就是01111…111, 0后面有一堆1。最高位是0, 和hash值相“与”,结果值一定不会比数组的长度值大,因此也就不会发生数组越界。

  • 保证元素尽可能的均匀分布

由上边的分析可知,tab.length一定是一个偶数,tab.length - 1一定是一个奇数。可以假想下如果数组长度是奇数的情况有什么问题?这种情况,数组长度减1后(tab.length - 1)就是偶数了,偶数对应的二进制最低位一定是 0,例如14二进制1110。比如对8、9两个数字分别“与”运算,得到的都是1000,那么哈希值8和9的元素都被存储在数组同一个index位置的链表中,造成哈希冲突,这是我们不愿意看到的。造成无法区分8和9的原因是因为最低位为0,与运算时丢失了一部分原hash的信息,所以tab.length保持偶数有助于避免哈希冲突,减小链表长度,提高效率。

(2)put(key,value)方法

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
	
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; 
	Node<K,V> p; 
	int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
		//如果table在(n-1)& hash的值是空,就新建一个节点插入在该位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
		//否则,表示有哈希冲突,开始处理冲突
        else {
            Node<K,V> e; 
	    K k;
		//检查第一个Node,p是不是要找的值 (注意这里先比较hash再比较key,why?)
            if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)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);
		//如果冲突的节点数已经达到8个,看是否需要改变冲突节点的存储结构;
      //treeifyBin首先判断当前hashMap的长度,如果不足64,只进行
          //resize,扩容table,如果达到64,那么将冲突的存储结构为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
					//如果有相同的key值就结束遍历
                    if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
			//就是链表上有相同的key值
			 existing mapping for key,就是key的Value存在
            if (e != null) { 
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;//返回存在的Value值
            }
        }
        ++modCount;	//fast-fail
     //如果当前大小大于门限,门限原本是初始容量*0.75
        if (++size > threshold)
            resize();//扩容两倍
        afterNodeInsertion(evict);
        return null;
    }
  • 判断键值对数组tab[]是否为空或为null,否则以默认16个元素resize()
  • 根据键值key计算hash值得到插入的数组索引i,如果tab[i]==null,直接新建节点添加(会有线程安全问题),否则转入下一步
  • 判断当前数组中处理hash冲突的方式为链表还是红黑树 (check第一个节点类型即可),分别处理,类似第一步查找是否有同hash并且同key的元素,更新或新增。

(3)resize()方法

...		
//Note that here we create a new table for transfer
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
 //把新表赋值给table
 table = newTab;
 //原表不是空要把原表中数据移动到新表中	
 if (oldTab != null) {
     // 遍历原来的旧表		
     for (int j = 0; j < oldCap; ++j) {
         Node<K,V> e;
         if ((e = oldTab[j]) != null) {
             oldTab[j] = null;
             if (e.next == null)
          		// 说明旧表node没有链表
          		// 直接放在新表的e.hash & (newCap - 1)位置
                 newTab[e.hash & (newCap - 1)] = e;
             else if (e instanceof TreeNode)
                 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
			//如果e后边有链表,到这里表示e后面带着个单链表
			// 需要遍历单链表,将每个结点重新计算在新表的位置,并进行搬运
             else { // preserve order保证顺序
                 Node<K,V> loHead = null, loTail = null;
                 Node<K,V> hiHead = null, hiTail = null;
                 Node<K,V> next;
                 do {
                     next = e.next;//记录下一个结点
					// 新表是旧表的两倍容量,实例上就把单链表拆分为两队,
            // e.hash&oldCap为0一队,e.hash&oldCap为1一对
                     if ((e.hash & oldCap) == 0) {
                         if (loTail == null)
                             loHead = e;
                         else
                             loTail.next = e;
                         loTail = e;
                     }
                     else {
                         if (hiTail == null)
                             hiHead = e;
                         else
                             hiTail.next = e;
                         hiTail = e;
                     }
                 } while ((e = next) != null);
				 //lo队不为null,放在新表原位置
                 if (loTail != null) {
                     loTail.next = null;
                     newTab[j] = loHead;
                 }	
                 //hi队不为null,放在新表j+oldCap位置
                 if (hiTail != null) {
                     hiTail.next = null;
                     newTab[j + oldCap] = hiHead;
                 }
             }
         }
     }
 }
 return newTab;
}

构造hash表时,如果不指明初始大小,默认大小为16(即Node数组大小16),如果Node[]数组中的元素达到(加载因子 * Node.length)重新调整HashMap大小,变为原来2倍大小。

jdk7中会对每个元素都重新计算一遍在新表中的index并移动,而jdk8中对构造新表的方法做了一些优化。

  • 如果e后边有链表,到这里表示e后面带着个单链表,需要遍历单链表,将每个结点重新计算在新表的位置,并进行搬运。

  • 新表是旧表的两倍容量,实例上就把单链表拆分为两队,e.hash&oldCap为偶数一队(lo),e.hash&oldCap为奇数一对(hi)。(思考一下为什么这么分,后面会解释)

  • lo队不为null,放在新表原位置;hi队不为null,放在新表j+oldCap位置

为什么根据e.hash&oldCap的结果分组呢?因为oldCap相对于oldCap-1会多出1个最高位bit,并且其他位都为0。利用这个特性,在扩充HashMap的时候,不再需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。

比如下面oldTab中第5位的元素,resize后,最高位为0的在5位置,为1的在16+5的位置。
在这里插入图片描述

(4)hash()方法

介绍完了HashMap的核心原理,再补充下hash() 函数,jdk1.8中,hash函数由java7的4次4位右移异或,变成了仅一次的16位右移异或,称为扰动函数。

// JDK7中使用
static int hash(int h) {
    // 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);
}

在这里插入图片描述
Java 8中的扰动函数混合了原始哈希码的高16位和低16位,低位掺杂了高位的部分特征,变相保留了高位,但是又只利用了有限的位数与(len-1)相与,而且计算逻辑更加简单。
在这里插入图片描述

通过hash生成下标的流程如下:
在这里插入图片描述

  1. 为什么重写equals方法需同时重写hashCode方法

在重写equals的方法的时候,必须注意重写hashCode方法,这样可以保证通过equals判断相等的两个对象,调用hashCode方法也会返回同样的整数值。因为不重写hashcode方法的话,equals方法返回为true的两个对象的hashcode可能会不相同,导致没有定位到同一个数组位置,返回逻辑上错误的值null。(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)

equals判断不相等的两个对象,其hashCode可能会相同,只不过会发生哈希冲突,应尽量避免。

  1. Hashmap的线程不安全问题
  • JDK1.7之前主要是扩容时的头插法造成的闭环死循环问题和数据丢失问题
  • JDK1.8之后主要是数据覆盖的问题。

1)死循环问题

死循环发生在HashMap的扩容函数中,根源在transfer函数中(简化)。

 void transfer(Entry[] newTable, boolean rehash) {
         int newCapacity = newTable.length;
         for (Entry<K,V> e : table) {
             while(null != e) {
                 Entry<K,V> next = e.next;
					...
                 int i = indexFor(e.hash, newCapacity);
                 e.next = newTable[i];                 
                 newTable[i] = e; //头插法,线程1挂起,线程2并行,造成闭环
                 e = next;
             }
         }
     }

2)数据覆盖

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                boolean evict) {
     Node<K,V>[] tab; Node<K,V> p; int n, i;
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     if ((p = tab[i = (n - 1) & hash]) == null) 
     		// 如果没有hash碰撞则直接插入元素
         tab[i] = newNode(hash, key, value, null);
     else {
		   ...

如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以线程A、B都会进入newNode方法中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

ConcurrentHashMap的底层数据结构

  1. JDK1.7实现

ConcurrentHashMap是由 Segment 数组、HashEntry 组成,和 HashMap 一样,仍然是数组加链表。但是其中的核心数据如value ,以及链表都是 volatile修饰的,保证了获取时的可见性。

ConcurrentHashMap 采用了分段锁技术,在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。其中, Segment 继承于 ReentrantLock。不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,支持线程并发。

虽然 HashEntry 中的 value 是用 volatile 关键词修饰的,但是并不能保证并发的原子性,所以 put 操作时仍然需要加锁处理。

  1. JDK1.8实现

抛弃了原有的 Segment 分段锁(最大并发度受Segment的个数限制,默认16),采用get()用volatile、put()用 CAS + synchronized 来保证并发安全性。

(1)get() 方法

查询时,首先通过tabAt()方法找到key对应的Node链表或红黑树,然后遍历该结构便可以获取key对应的value值。其中,tabAt()方法主要通过Unsafe类的getObjectVolatile()方法获取value值,通过volatile读取value值,可以保证可见性,从而保证其是当前最新值。

    public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

(2)put() 方法

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;  // no lock (CAS) when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {	//锁住桶的头元素
            .....
}

a. 计算keyhash值,即调用扰动函数spread()方法计算hash值;
b. 获取hash值对应的Node节点位置,此时通过一个循环实现。有以下几种情况:

  • 如果table表为空,则首先进行初始化操作,初始化之后再次进入循环获取Node节点的位置;
  • 如果table不为空,但没有找到key对应的Node节点(无哈希冲突),则直接调用casTabAt()方法插入一个新节点,此时不用加锁;
  • 如果table不为空,且key对应的Node节点也不为空,但Node头结点的hash值为MOVED(-1),则表示需要扩容,此时调用helpTransfer()方法进行扩容;
  • 其他情况下(更新Node节点),则直接向Node中插入一个新Node节点,此时需要对这个Node链表或红黑树通过synchronized加锁。

c. 插入元素后,判断对应的Node结构是否需要改变结构,如果需要则调用treeifyBin()方法将Node链表升级为红黑树结构;
d. 最后,调用addCount()方法记录table中元素的数量。

(3)补充

  • 如果当前table的长度大于64,则使用CAS获取指定的Node节点,然后对该节点通过synchronized加锁,由于只对一个Node节点加锁,因此该操作并不影响其他Node节点的操作,因此极大的提高了ConcurrentHashMap的并发效率。加锁之后,便是将这个Node节点所在的链表转换为TreeBin结构的红黑树。

  • JDK1.8中的ConcurrentHashMap中还包含一个重要属性sizeCtl,其是一个控制标识符,不同的值代表不同的意思:其为0时,表示hash表还未初始化,而为正数时这个数值表示初始化或下一次扩容的大小,相当于一个阈值;即如果hash表的实际大小>=sizeCtl,则进行扩容,默认情况下其是当前ConcurrentHashMap容量的0.75倍;而如果sizeCtl为-1,表示正在进行初始化操作;而为-N时,则表示有N-1个线程正在进行扩容。

ArrayList底层原理,ArrayList和Vector/LinkedList的区别

  1. ArrayList

每当向ArrayList数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容(创建时默认为10),以满足添加数据的需求。数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。

jdk7:

  • ArrayList中维护了Object[] elementData,初始容量为10.
  • 添加时,如果容量足够,则不用扩容直接将新元素赋值到第一个空位上
  • 如果容量不够,会扩容1.5倍。

jdk8

  • ArrayList中维护了Object[] elementData,初始容量为0.
  • 第一次添加时,将初始elementData的容量为10
  • 再次添加时,如果容量足够,则不用扩容直接将新元素赋值到第一个空位上,如果容量不够,会扩容1.5倍。
    /**
     * ArrayList扩容的核心方法。
     */
    private void grow(int minCapacity) {
        // oldCapacity为旧容量,newCapacity为新容量
        int oldCapacity = elementData.length;
        //将oldCapacity 右移一位,其效果相当于oldCapacity /2,
        //我们知道位运算的速度远远快于整除运算,
        //整句运算式的结果就是将新容量更新为旧容量的1.5倍,
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,
        //那么就把最小需要容量当作数组的新容量,
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        //再检查新容量是否超出了ArrayList所定义的最大容量,
        //若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,
        //如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为
        //Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。
        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);
    }
  1. ArrayList和Vector
    在这里插入图片描述
  • Vector是线程安全的,ArrayList不是线程安全的。

  • ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍
    在这里插入图片描述
    在这里插入图片描述

  1. ArrayList和LinkedList
  • ArrayList是实现了基于动态数组的数据结构,而LinkedList是基于链表的数据结构
  • 对于随机访问get和set,ArrayList要优于LinkedList,因为LinkedList要移动指针
  • 对于添加和删除操作,一般LinkedList要比ArrayList快,因为ArrayList要移动数据

String,StringBuffer,StringBuilder的区别 扩展:String不可变有什么好处?

  • String 字符串常量
  • StringBuffer 字符串变量(线程安全)
  • StringBuilder 字符串变量(非线程安全)
  1. String类源码
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    ...
}

String类被final关键字修饰,意味着String类不能被继承,并且它的成员方法都默认为final方法;字符串一旦创建就不能再修改。String实例的值是通过final修饰的字符数组实现字符串存储的。

String 是不可变的对象,因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象,所以经常改变内容的字符串最好不要用 String ,因为每次生成对象都会对系统性能产生影响,特别当内存中无引用对象多了以后, JVM 的 GC 就会开始工作,那速度是一定会相当慢的。

  1. final关键字

(1)修饰类的时候,说明该类不能被继承
(2)修饰方法的时候,说明该方法不能被重写
(3) 修饰成员变量时,有两种情况:

  • 如果修饰的是基本类型,说明这个变量所代表的数值永不能变
  • 如果修饰的是引用类型,该变量的引用不能变
  1. 字符串常量
String str= "123";
str = "456";	//只是修改str存的引用地址而已
str1 = "789"
System.out.println(str + str1);
//底层:new StringBuilder().append(str).append(str1).toString();

str2 = "789"
String str3 = new String("789");
System.out.println(str1 == str2);	//true
System.out.println(str1 == str3);	//false
  • 不可变类只是其实例不能被修改的类。
  • 由于常量池中不存在两个相同的对象,所以str1str2都是指向JVM字符串常量池中的"789"对象。new关键字一定会产生一个对象,并且这个对象存储在堆中。所以String str3 = new String("789");产生了两个对象:保存在栈中的str3引用和保存在堆中的String对象。
    在这里插入图片描述
    其实 final 修饰的仅仅只是 value 这个引用,你无法再将 value 指向其他内存地址,而且在 String 中有许多对字符串进行操作的函数,例如substring、concat、replace、replaceAll 等等,这些函数是否会修改类中的 value 域呢?答案是否定的。这些操作一般会先使用 Arrays.copyOf() 方法来获得 value 的拷贝,最后重新 new 一个String对象作为操作完成的返回值,每一步操作都不会对 value产生任何影响。

JVM 为了字符串的复用,减少字符串对象的重复创建,特别维护了一个字符串常量池。下面第一行写法会直接在字符串常量池中查找是否存在值 123,若存在直接返回这个值的引用,若不存在创建一个值为 123 的 String 对象并存入字符串常量池中。第二行使用 new 关键字生成的 String 对象也可以进入字符串常量池。

  String str1 = "123";	//方法区中的字符串常量池
  String str2 = new String("123");	//堆区
  System.out.println(str1 == str2); //output:false

常量池是方法区(jdk8移入堆中)的一部分,用于存放编译期生成的各种字面量和符号引用,运行期间也有可能将新的常量放入池中。在 Java 虚拟机规范中把方法区描述为堆的一个逻辑部分,但它却有一个别名叫Non-Heap,目的应该是为了和 Java 堆区分开。

  1. String不可变类的优点
  • 不可变类比较简单。
  • 不可变对象本质上是线程安全的,它们不要求同步。不可变对象可以被自由地共享。
  • 不仅可以共享不可变对象,甚至可以共享它们的内部信息。
  • 不可变对象为其他对象提供了大量的构建。
  • 不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象
  1. String 的不可变性可破坏

通过反射可以破坏 String 的不可变性

String str = "123";
System.out.println(str);
Field field = String.class.getDeclaredField("value");
field.setAccessible(true);
char[] value = (char[]) field.get(str);
value[1] = '3';

迭代器是做什么的?迭代器的fail-fast机制了解吗?主要为了解决什么问题?

迭代器是一种设计模式,它是一个对象,可以用来遍历并选择序列中的对象,开发人员不需要了解该序列的底层结构。

从源代码里可以看到增删操作都会使modCount++,通过和expectedModCount的对比,迭代器可以快速的知道迭代过程中是否存在list.add()类似的操作,存在的话快速失败!具体表现为,如果在使用迭代器的过程中有其他的线程修改了List的结构(包括增、删)就会抛出ConcurrentModificationException,即并发修改异常,这就是Fail-Fast机制。

import java.util.*;
public class Muster {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        list.add("a");
        list.add("b");
        list.add("c");
        Iterator it = list.iterator();
        while(it.hasNext()){
            String str = (String) it.next();
            System.out.println(str);
            //modcount++,modcount等于4,expecctedmodcount等于3
            // 检查到modcount != expecctedmodcount,fail!
            // 会抛出一个ConcurrentModificationException。
            list.add("s");        
        }
    }
}

抽象类与接口的区别

  1. 从定义上看,接口是个集合,并不是类。类描述了属性和方法,而接口只包含方法(未实现的方法)。

接口和抽象类一样不能被实例化,因为不是类。但是接口可以被实现(使用 implements 关键字)。实现某个接口的类必须在类中实现该接口的全部方法。虽然接口内的方法都是抽象的(和抽象方法很像,没有实现)但是不需要abstract关键字。

  1. 接口中没有构造方法(因为接口不是类)

  2. 接口中的方法必须是抽象的(不能实现)

  3. 接口中除了static、final变量,不能有其他变量

  4. 接口支持多继承(一个类可以实现多个接口)

什么是CAS算法?CAS底层做了什么?CAS可能产生什么问题?

  1. 概念

CAS,即compare and swap(比较与交换),是一种有名的无锁算法。CAS机制当中使用了3个基本操作数:内存地址V旧的预期值A要修改的新值B。是一种乐观锁。

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

  1. 缺点

(1)CPU可能开销较大

在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

(2)不能保证代码块的原子性

CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用悲观锁了。

(3)ABA问题。

CAS的核心思想是通过比对内存值与预期值是否一样而判断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是A,后来被一条线程改为B,最后又被改成了A,则CAS认为此内存值并没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。

知道AQS框架吗?说一下AQS的底层原理

重点掌握AQS对于线程排队的过程和存储线程的数据结构

Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于抽象的队列式同步器AbstractQueuedSynchronizer(简称为AQS)实现的。AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。

  1. 原理

AQS 维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这就是AQS机制

AQS基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
在这里插入图片描述
AQS 定义了两种资源共享方式:

  • Exclusive:独占,只有一个线程能执行,如ReentrantLock
  • Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

reentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock(),state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock

synchronized和Lock分别在什么情况下使用,使用的理由。synchronized锁膨胀过程。

  1. 概念解释

(1)可重入锁

在执行对象中所有同步方法不用再次获得锁。synchronizedReentrantLock,一个方法中的内嵌方法可以直接获取锁(state++)

(2)可中断锁

在等待获取锁的过程中可中断。Lock接口中的lockInterruptibly()方法就体现了Lock的可中断性。

(3)公平锁

按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利。synchronized是非公平锁,它无法保证等待的线程获取锁的顺序。对于ReentrantLock和ReentrantReadWriteLock,默认情况下是非公平锁,但是可以设置为公平锁。

(4)读写锁

对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写。 ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。

  1. synchronized和Lock
    在这里插入图片描述

(1)Lock类方法

  • lock():获取锁,如果锁被占用则一直等待
  • unlock():释放锁
  • tryLock():注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true
  • tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,保证等待参数时间
  • lockInterruptibly():用该锁的获得方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事

(2)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现的

(3)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;

(4)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断

(5)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

(6)Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)

(7)性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。原因是ReentrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。

  1. synchronized锁膨胀(锁升级)机制

synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的。Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。在编译后会对同步代码块前后生成MonitorenterMonitorexit的字节码指令。

如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
在这里插入图片描述
(1)无锁(cas操作)

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。CAS,即compare and swap(比较与交换),是一种有名的无锁算法。

(2)偏向锁(对象头)

当一个线程访问同步代码块并获取到锁时,会在Mark Word里存储锁偏向的线程ID(关于对象头的部分在后面有详细讨论)。在之后,线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换线程ID的时候依赖一次CAS原子指令即可。

(3)轻量级锁(自旋)

是指当锁是偏向锁,被另外的线程所访问时,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。

若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

(4)重量级锁(阻塞)

升级为重量级锁时,此时Mark Word中存储的是指向重量级Monitor锁的指针,此时等待锁的线程都会进入阻塞状态。monitor锁本质是调用操作系统底层互斥信号量实现。操作系统需要从用户态切换到内核态,保存线程上下文信息,执行完成后再恢复上下文信息,转换成本很高,所以synchronized效率低。
在这里插入图片描述
综合来说,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

java中各种锁的实现和使用场景

在这里插入图片描述

  1. 悲观锁

每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。例如Java中synchronizedReentrantLock

  1. 乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。Java原子类中的递增操作就通过CAS自旋实现的。

版本号机制:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值和当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升
  1. 自旋锁

两个线程竞争同一把锁,可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
在这里插入图片描述

可重入锁和非可重入锁的区别

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞,造成死锁。

ReentrantLock和NonReentrantLock都是重入锁,都是通过继承父类AQS实现的,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。

在这里插入图片描述

线程池的七个参数,线程池的好处

线程池(Thread Pool)是一种基于池化思想管理线程的工具,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

  1. 优点
  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。
  1. ThreadPoolExecutor

ThreadPoolExecutor实现的顶层接口是Executor,顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。

  • corePoolSize 核心线程数
  • maxPoolSize 最大线程数
  • keepAliveTime 生存时间
  • unit 时间单位
  • workQueue:workQueue:一个阻塞队列,用来存储等待执行的任务。
    在这里插入图片描述
  • threadFactory 线程工厂
  • handler 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
    在这里插入图片描述
  1. 工作流程

(1)首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
(2)如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
(3)如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
(4)如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
(5)如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

  1. 线程池状态:
    在这里插入图片描述

java中有哪些常用的线程池

  1. newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程(直至0),若无可回收,则新建线程,核心线程数等于0。

  2. newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待,核心线程数等于最大线程数。

  3. newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

  4. newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

如果一个线程要在其他几个线程运行完之后运行,有什么办法?

  1. CountDownLatch

(1)使用
利用它可以实现类似计数器的功能。比如有一个任务A,它要等待其他4个任务执行完毕之后才能执行,

public CountDownLatch(int count) {  };  //参数count为计数值
   //然后下面这3个方法是CountDownLatch类中最重要的方法:
public void await() throws InterruptedException { };   //调用await()方法的线程会被//挂起,它会等待直到count值为0才继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };  //和await()类似,只不过等待一定的时间后count值还没变为0的话就会继续执行
public void countDown() { };  //将count值减1

有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止。

(2)原理

a. 当我们调用CountDownLatch countDownLatch=new CountDownLatch(4) 时候,此时会创建一个AQS的同步队列,并把创建CountDownLatch传进来的计数器赋值给AQS队列的 state,所以state的值也代表CountDownLatch所剩余的计数次数

b. 当我们调用countDownLatch.await()的时候,若计数未结束,会创建一个节点,加入到AQS阻塞队列,并同时把当前线程挂起

c. 当我们调用countDownLatch.countDown()方法的时候,会对计数器进行减1操作,AQS内部是通过释放锁的方式,对state进行减1操作,当state=0的时候证明计数器已经递减完毕,此时会将AQS阻塞队列里的节点线程全部唤醒。

  1. CyclicBarrier

回环栅栏,通过它可以实现让一组线程等待至某个状态之后再全部同时执行。叫做回环是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用。我们暂且把这个状态就叫做barrier,当调用await()方法之后,线程就处于barrier了。

  1. Semaphore

信号量,Semaphore可以控制同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。

多线程在运行过程中抛出异常怎么捕获?

首先,一个线程中无法通过try catch捕获另外一个线程的异常,因为线程是独立执行的代码片断,线程的问题应该由线程自己来解决,而不要委托到外部,换句话说,我们不能捕获从线程中逃逸的异常。

基于这样的设计理念,在Java中,线程方法的异常都应该在线程代码边界之内(run方法内)进行try catch并处理掉,但在多线程场景下,每个线程都需要编写重复的try catch 代码,比较麻烦。有没有其他办法?答案是有。当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器。

  1. 默认异常处理器
// 设置默认的线程异常捕获处理器
Thread.setDefaultUncaughtExceptionHandler(new MyUnchecckedExceptionhandler());
  1. 给每个线程设置特定的异常处理器
Thread t = new Thread(new ExceptionThread());
t.setUncaughtExceptionHandler(new MyUnchecckedExceptionhandler());
t.start();
  1. 给线程池设置异常处理器

因为线程池也是通过new Thread()的方式创建的线程,可以重写线程工厂的newThread方法。

ExecutorService exec = Executors.newCachedThreadPool(new ThreadFactory(){
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setUncaughtExceptionHandler(new MyUnchecckedExceptionhandler());
                return thread;
            }
});
exec.execute(new ExceptionThread());

注意:execute()与submit()方式对异常处理的不同。

  1. 使用FutureTask来捕获异常
//1.创建FeatureTask
FutureTask<Integer> futureTask = new FutureTask<>(new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        return 1/0;
    }
});

//2.创建Thread
Thread thread = new Thread(futureTask);

//3.启动线程
thread.start();
try {
    Integer result = futureTask.get();
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (ExecutionException e) {
    //4.处理捕获的线程异常
}
  1. 利用线程池提交线程时返回的Future引用
//1.创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(10);

//2.创建Callable,有返回值的,你也可以创建一个线程实现Callable接口。
//如果你不需要返回值,这里也可以创建一个Thread即可,在第3步时submit这个thread。
Callable<Integer> callable = new Callable<Integer>() {
    @Override
    public Integer call() throws Exception {
        return 1/0;
    }
};

//3.提交待执行的线程
Future<Integer> future = executorService.submit(callable);
try {
     Integer result = future.get();
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (ExecutionException e) {
    //4.处理捕获的线程异常
}
  1. 线程创建

在本质上,创建线程只有一种方式,就是构造一个 Thread 类(其子类其实也可以认为是一个 Thread 类)。

而构造 Thread 类又有两种方式,一种是继承 Thread 类,一种是实现 Runnable接口。其最终都会创建 Thread 类(或其子类)的对象。
在这里插入图片描述

再来看实现 Callable ,结合 Future 和 FutureTask 的方式。可以发现,其最终也是通过 new Thread(task) 的方式构造 Thread 类。Callable也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call()。

因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了FutureTask,FutureTask实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
在这里插入图片描述

如果想把两个线程的结果拿到进行下一步操作怎么做

可以采用CompletableFuture,CompletableFuture是由Java 8引入的,在Java8之前我们一般通过Future实现异步。

  • Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,Java 8之前若要设置回调一般会使用guava的ListenableFuture,回调的引入比较复杂。

  • CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,回调引入更加简洁。
    在这里插入图片描述

一个业务接口的流程可能包括CF1\CF2\CF3\CF4\CF5共5个步骤,步骤之间存在依赖关系,每个步骤可以是一次RPC调用、一次数据库操作或者是一次本地方法调用等,在使用CompletableFuture进行异步化编程时,图中的每个步骤都会产生一个CompletableFuture对象,最终结果也会用一个CompletableFuture来进行表示。
在这里插入图片描述
整个流程的结束依赖于三个步骤CF3、CF4、CF5,这种多元依赖可以通过allOf或anyOf方法来实现,区别是当需要多个依赖全部完成时使用allOf,当多个依赖中的任意一个完成即可时使用anyOf。
在这里插入图片描述

// 不处理返回值
CompletableFuture memberFuture = CompleteableFuture.runAsyc(...);
// 等待多个线程返回,再执行
CompletableFuture.allof(memberFuture,wareFuture).get();

每个CompletableFuture都可以被看作一个被观察者,其内部有一个Completion类型的链表成员变量stack,用来存储注册到其中的所有观察者。当被观察者执行完成后会弹栈stack属性,依次通知注册到其中的观察者。上面例子中步骤fn2就是作为观察者被封装在UniApply中。
被观察者CF中的result属性,用来存储返回结果数据。这里可能是一次RPC调用的返回值,也可能是任意对象,在上面的例子中对应步骤fn1的执行结果。

sleep和wait的区别

  1. 锁释放
  • sleep()是Thread类的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复;
  • wait()是Object类的方法,调用会放弃对象锁,进入等待队列,待调用notify()/notifyAll()唤醒指定的线程或者所有线程,才会进入锁池,再次获得对象锁才会进入运行状态。注意,notify的作用相当于叫醒睡着的人,而并不会给他分配任务,就是说notify只是让之前调用wait的线程有权利重新参与线程的调度
  1. 使用时机
  • sleep()方法可以在任何地方使用,目的是暂停线程执行;

  • 由于wait/notify调用都需要线程持有对象的锁,这样就只能通过同步来实现,所以wait()只能在同步方法或者同步块中被调用。

ThreadLocal可以解决什么问题?具体的应用场景?

(可以用ThreadLocal存储当前线程的数据库连接,分布式链路追踪中可以存放当前线程对应的链路信息)

  1. 概念

ThreadLocal提供了线程的局部变量,每个线程都可以通过set()get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。
在这里插入图片描述
2. 应用场景

  • ThreadLocal存储当前线程的数据库连接,ThreadLocal能够实现当前线程的操作都是用同一个Connection,保证了事务!
//获取Connection对象
Connection connection = source.getConnection();

//把Connection放进ThreadLocal里面
threadLocal.set(connection);

//返回Connection对象
return threadLocal.get();
  • 浏览器就相当于我们的ThreadLocal,它仅仅会发送当前浏览器存在的Cookie(ThreadLocal的局部变量),不同的浏览器对Cookie是隔离的。
  1. threadLocal.get()方法
    在这里插入图片描述

ThreadLocalMap.get 获取的是Thread中的一个对象,因为获取到的是当前线程的ThreadLocalMap,各个线程所以互不干扰。

ThreadLocalMap getMap(Thread t) {
	return t.threadLocals; }
  1. 原理

(1)每个Thread维护着一个ThreadLocalMap的引用
(2)ThreadLocalMapThreadLocal的内部类,用Entry来进行存储
(3)调用ThreadLocalset()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象
(4)调用ThreadLocalget()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
(5) ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value

  1. 内存泄漏问题在这里插入图片描述
    实线代表强引用,虚线代表的是弱引用,如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条引用链路可达,很显然在gc(垃圾回收)的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value

同时,却存在这样一条引用链:threadRef->currentThread->threadLocalMap->entry->valueRef->valueMemory,导致在垃圾回收的时候进行可达性分析的时候,value可达从而不会被回收掉,但是该value永远不能被访问到,这样就产生了内存泄漏

当然,如果线程执行结束后,threadLocal,threadRef会断掉,因此threadLocal,threadLocalMap,entry都会被回收掉。可是,在实际使用中我们都是会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了复用,线程是不会结束的,造成value也不会回收,所以使用完后记得remove。

ReenTrantLock中的condition有什么作用?condition的await和signal和Object的wait和notify有什么区别?

  1. condition优势

Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。

Condition可以实现多路通知功能,也就是在一个Lock对象里可以创建多个Condition(即对象监视器)实例,线程对象可以注册在指定的Condition中,从而可以有选择的进行线程通知,在调度线程上更加灵活。synchronized就相当于整个Lock对象中只有一个单一的Condition对象,所有的线程都注册在这个对象上。线程开始notifyAll时,需要通知所有的WAITING线程,没有选择权,会有相当大的效率问题。
在这里插入图片描述

  1. 与synchronized不同

(1)与synchronized相比,ReentrantLock提供了更多,更加全面的功能,具备更强的扩展性。例如:时间锁等候,可中断锁等候,锁投票。

(2)ReentrantLock还提供了条件Condition,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock更加适合(下面会阐述Condition)。

(3)ReentrantLock提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而synchronized则一旦进入锁请求要么成功,要么一直阻塞(锁升级之前),所以相比synchronized而言,ReentrantLock会不容易产生死锁些。

(4)ReentrantLock支持更加灵活的同步代码块,但是使用synchronized时,只能在同一个synchronized块结构中获取和释放。注:ReentrantLock的锁释放一定要在finally中处理,否则可能会产生严重的后果。

(5)ReentrantLock支持中断处理,且性能较synchronized会好些。

volatile主要解决了什么问题?

  1. 特点

volatile修饰的共享变量,具有以下两点特性:

  • 保证了不同线程对该变量操作的内存可见性;

  • 禁止指令重排序

在这里插入图片描述
在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。

  1. 作用

在这种内存模型下,如何处理原子性、可见性和有序性?

volatile可提供可见性和有序性,但不能保证原子性。

(1)可见性

Java就是利用volatile来提供可见性的。 当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值。而普通变量则不能保证这一点。 volatile 的可见性是基于总线嗅探实现的。

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

(2)有序性

JMM是允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即不管怎么重排序,程序的执行结果不能改变。

对一个volatile域的写,happens-before于后续对这个volatile域的读。
这条再拎出来说,其实就是如果一个变量声明成是volatile的,那么当我读变量时,总是能读到它的最新值,这里最新值是指不管其它哪个线程对该变量做了写操作,都会立刻被更新到主存里,我也能从主存里读到这个刚写入的值。

volatile关键字会禁止指令重排,可以确保程序的“有序性”,也可以上重量级的synchronizedLock来保证有序性,它们能保证那一块区域里的代码都是一次性执行完毕的。

  1. volatile原理

加入volatile关键字的代码会多出一个lock前缀指令,lock前缀指令实际相当于一个内存屏障,
内存屏障提供了以下功能:

  • 重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 使得本CPU的Cache写入内存
  • 写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。
  1. 总线嗅探

为了提高处理速度,CPU 不直接和内存进行通信,而是在 CPU 与内存之间加入很多寄存器,多级缓存,它们比内存的存取速度高得多,这样就解决了 CPU 运算速度和内存读取速度不一致问题。但在多处理器下,将可能导致各自的缓存数据不一致(这也是可见性问题的由来),为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,而嗅探是实现缓存一致性的常见机制。

嗅探机制工作原理:每个处理器通过监听在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存中。

注意:基于 CPU 缓存一致性协议,JVM 实现了 volatile 的可见性,但由于总线嗅探机制,会不断的监听总线,如果大量使用 volatile 会引起总线风暴。所以,volatile 的使用要适合具体场景。

static详解

被static关键字修饰的方法或者变量不需要依赖于对象来进行访问,只要类被加载了,就可以通过类名去进行访问。在《Java编程思想》P86页有这样一段话:

“ static方法就是没有this的方法。在static方法内部不能调用非静态方法,反过来是可以的。而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用static方法。这实际上正是static方法的主要用途。”

  1. static方法

在静态方法中不能访问非静态成员方法和非静态成员变量,但是在非静态成员方法中是可以访问静态成员方法/变量的。

  1. static变量

static变量也称作静态变量,静态变量和非静态变量的区别是:静态变量被所有的对象所共享,在内存中只有一个副本,它当且仅当在类初次加载时会被初始化。而非静态变量是对象所拥有的,在创建对象的时候被初始化,存在多个副本,各个对象拥有的副本互不影响。

  1. static代码块

static关键字一个比较关键的作用就是用来生成静态代码块以优化程序性能。static块可以置于类中的任何地方,类中可以有多个static块。在类初次被加载的时候,会按照static块的顺序来执行每个static块,并且只会执行一次。

class Person{
    private Date birthDate;
    private static Date startDate,endDate;
    
    //isBornBoomer是用来判断这个人是否是1946-1964年出生的,而每次isBornBoomer
    //被调用的时候,都会生成startDate和birthDate两个对象,造成了空间浪费,如果改
    //成这样效率会更好,只需要进行一次的初始化操作都放在static代码块中进行。
    static{
        startDate = Date.valueOf("1946");
        endDate = Date.valueOf("1964");
    }
     
    public Person(Date birthDate) {
        this.birthDate = birthDate;
    }
     
    boolean isBornBoomer() {
        return birthDate.compareTo(startDate)>=0 && birthDate.compareTo(endDate) < 0;
    }
}

注意:

  • Java中的static关键字不会影响到变量或者方法的作用域。在Java中能够影响到访问权限的只有private、public、protected这几个关键字。

  • static不允许用来修饰局部变量。

实例:

public class Test extends Base{
 
    static{
        System.out.println("test static");
    }
     
    public Test(){
        System.out.println("test constructor");
    }
    
    //入口 
    public static void main(String[] args) {
        new Test();
    }
}
 
class Base{
     
    static{
        System.out.println("base static");
    }
     
    public Base(){
        System.out.println("base constructor");
    }
}

output:
base static
test static
base constructor
test constructor

在执行开始,先要寻找到main方法,因为main方法是程序的入口,但是在执行main方法之前,必须先加载Test类,而在加载Test类的时候发现Test类继承自Base类,因此会转去先加载Base类,在加载Base类的时候,发现有static块,便执行了static块。

在Base类加载完成之后,便继续加载Test类,然后发现Test类中也有static块,便执行static块。在加载完所需的类之后,便开始执行main方法。在main方法中执行new Test()的时候会先调用父类的构造器,然后再调用自身的构造器。

反射的原理,有什么应用

在程序运行时,每个java类都在JVM里表现为一个class对象,可通过类名.class、类型.getClass()、Class.forName(“类名”) 等方法获取class对象,通过class对象就能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性。这种动态的获取信息,以及动态调用对象的方法的功能称为 java 的反射机制。

private static void getPrivateMethod() throws Exception{
    //1. 获取 Class 类实例
    TestClass testClass = new TestClass();
    Class mClass = testClass.getClass();
    
    //2. 获取私有方法
    //第一个参数为要获取的私有方法的名称
    //第二个为要获取方法的参数的类型,参数为 Class...,没有参数就是null
    //方法参数也可这么写 :new Class[]{String.class , int.class}
    Method privateMethod =
            mClass.getDeclaredMethod("privateMethod", String.class, int.class);
            
    //3. 开始操作方法
    if (privateMethod != null) {
        //获取私有方法的访问权
        //只是获取访问权,并不是修改实际权限
        privateMethod.setAccessible(true);
        
        //使用 invoke 反射调用私有方法
        //privateMethod 是获取到的私有方法
        //testClass 要操作的对象
        //后面两个参数传实参
        privateMethod.invoke(testClass, "Java Reflect ", 666);
    }
}

应用场景:

  1. jdbc数据库连接
    在这里插入图片描述
    2.XML配置文件
    在这里插入图片描述
    在这里插入图片描述

动态代理

  1. 静态代理

创建代理对象,实现和目标对象相同的接口,通过构造器塞入一个目标对象,然后在代理对象的方法内部调用目标对象同名方法,并在调用前后打印日志。也就是说,代理对象 = 增强代码 + 目标对象(原对象)。
在这里插入图片描述
这样做的问题是需要手动为每一个目标类编写对应的代理类。如果当前系统已经有成百上千个类,工作量太大了。所以,现在我们的努力方向是:如何少写或者不写代理类,却能完成代理功能?

  1. 动态代理

在这里插入图片描述
核心:动态代理就是想办法,根据接口或目标对象(有了代理对象需要的一切方法和字段),计算出代理类的字节码,然后再加载到JVM中,生成代理对象。有两种方式:

  • 通过实现接口的方式 -> JDK动态代理
  • 通过继承类的方式 -> CGLIB动态代理

总结:无论是静态代理还是动态代理本质都是最终生成代理对象,区别在于静态代理对象需要人手动生成,而动态代理对象是运行时,JDK通过反射动态生成的代理类构造的对象。

(1)JDK原生动态代理
在这里插入图片描述
JDK动态代理主要涉及两个类:java.lang.reflect.Proxyjava.lang.reflect.InvocationHandler

public class ProxyTest {
	public static void main(String[] args) throws Throwable {
		CalculatorImpl target = new CalculatorImpl();
		Calculator calculatorProxy = (Calculator) getProxy(target);
		calculatorProxy.add(1, 2);
		calculatorProxy.subtract(2, 1);
	}

	private static Object getProxy(final Object target) throws Exception {
		Object proxy = Proxy.newProxyInstance(
				target.getClass().getClassLoader(),/*类加载器*/
				target.getClass().getInterfaces(),/*让代理对象和目标对象实现相同接口*/
				new InvocationHandler(){ /*代理对象的方法最终都会被JVM导向它的invoke方法*/
					public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
						System.out.println(method.getName() + "方法开始执行...");
						Object result = method.invoke(target, args);
						System.out.println(result);
						System.out.println(method.getName() + "方法执行结束...");
						return result;
					}
				}
		);
		return proxy;
	}
}
  • Proxy.getProxyClass(ClassLoader, interfaces),只要你给它传入类加载器和一组接口,它就给你返回代理Class对象。代理Class其实就是附有构造器的接口Class,一样的类结构信息,你可以借助它通过getConstructor获得构造方法,再通过构造方法newInstance(invocationHandler)创建实例。

在这里插入图片描述

  • 之后,InvocationHandler对象成了代理对象和目标对象的桥梁,invocationHandler是代理对象的内部的一个成员变量,代理对象的每个方法内部都会调用invocationHandler.invoke()方法。
  • Proxy.newProxyInstance进一步隐藏了以上细节,只要你给它传入类加载器、一组接口,和一个实现了invoke方法的InvocationHandler对象,就直接给你返回代理对象,每次调用方法都执行到invoke上。
    在这里插入图片描述

(2)CGLIB动态代理

1)查找目标类上的所有非final 的public类型的方法定义;
2)将这些方法的定义转换成字节码;
3)将组成的字节码转换成相应的代理的class对象;
4)实现 MethodInterceptor接口,用来处理对代理类上所有方法的请求
5) 主要涉及两个类,还有一个是Enhancer类,用来设置和生成代理对象

public class LogInterceptor2 implements MethodInterceptor {
    @Override
    public Object intercept(Object object, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        before();
        Object result = methodProxy.invokeSuper(object, objects);
        after();
        return result;
    }
    private void before() {
        System.out.println(String.format("log2 start time [%s] ", new Date()));
    }
    private void after() {
        System.out.println(String.format("log2 end time [%s] ", new Date()));
    }
}

// 回调过滤器: 在CGLib回调时可以设置对不同方法执行不同的回调逻辑,或者根本不执行回调。
public class DaoFilter implements CallbackFilter {
    @Override
    public int accept(Method method) {
        if ("select".equals(method.getName())) {
            return 0;   // Callback 列表第1个拦截器
        }
        return 1;   // Callback 列表第2个拦截器,return 2 则为第3个,以此类推
    }
}

//测试
public class CglibTest2 {
    public static void main(String[] args) {
        LogInterceptor logInterceptor = new LogInterceptor();
        LogInterceptor2 logInterceptor2 = new LogInterceptor2();
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(UserDao.class);   // 设置超类,cglib是通过继承来实现的
        enhancer.setCallbacks(new Callback[]{logInterceptor, logInterceptor2, NoOp.INSTANCE});   // 设置多个拦截器,NoOp.INSTANCE是一个空拦截器,不做任何处理
        enhancer.setCallbackFilter(new DaoFilter());

        UserDao proxy = (UserDao) enhancer.create();   // 创建代理类
        proxy.select();
        proxy.update();
    }
}

(3)区别

  • JDK动态代理:基于Java反射机制实现,必须要实现了接口的业务类才能用这种办法生成代理对象。解决了静态代理中冗余的代理实现类问题,必须依赖接口。

  • cglib动态代理:基于ASM机制实现,通过生成业务类的子类作为代理类。没有接口也能实现动态代理,而且采用字节码增强技术,性能也不错。

泛型可以用Object代替吗?泛型擦除

【拓展】

  1. 问题

早期Java是使用Object来代表任意类型的,但是这样做对于Collection、Map集合对插入元素的类型是没有任何限制的,而且获取元素时向下转型也有强转的问题,这样程序就不太安全。

  1. 泛型是啥

泛型本质是参数化类型,解决不确定对象具体类型的问题。泛型在定义处只具备执行 Object 方法的能力。泛型只在编译阶段有效。编译器会在编译阶段就能够帮我们发现类似ClassCastException这样的问题。编译之后程序会采取去泛型化的措施。也就是说Java中的泛型,只在编译阶段有效。在编译过程中,正确检验泛型结果后,会将泛型的相关信息擦出,并且在对象进入和离开方法的边界处添加类型检查和类型转换的方法。也就是说,泛型信息不会进入到运行时阶段

  1. 泛型优点
  • 代码更加简洁【不用强制转换】
  • 程序更加健壮【只要编译时期没有警告,那么运行时期就不会出现ClassCastException异常】
  • 可读性和稳定性【在编写集合的时候,就限定了类型】
  1. 泛型擦除

泛型是提供给javac编译器使用的,它用于限定集合的输入类型,让编译器在源代码级别上,即挡住向集合中插入非法数据。但编译器编译完带有泛形的java程序后,生成的.class文件中将不再带有泛形信息,以此使程序运行效率不受到影响,这个过程称之为“泛型擦除”。

定义一个泛型类型,会自动提供一个对应原始类型,类型变量会被擦除。如果没有限定类型就会替换为 Object,如果有限定类型就会替换为第一个限定类型,例如 <T extends A & B> 会使用 A 类型替换 T。

JDK8 新特性有哪些?

  1. lambda 表达式:允许把函数作为参数传递到方法,简化匿名内部类代码。

  2. 函数式接口:使用 @FunctionalInterface 标识,有且仅有一个抽象方法,可被隐式转换为 lambda 表达式

  3. 方法引用:可以引用已有类或对象的方法和构造方法,进一步简化 lambda 表达式。

  4. 接口:接口可以定义 default 修饰的默认方法,降低了接口升级的复杂性,还可以定义静态方法。

  5. 注解:引入重复注解机制,相同注解在同地方可以声明多次。注解作用范围也进行了扩展,可作用于局部变量、泛型、方法异常等。

  6. 类型推测:加强了类型推测机制,使代码更加简洁。

  7. Optional 类:处理空指针异常,提高代码可读性。

  8. Stream 类:引入函数式编程风格,提供了很多功能,使代码更加简洁。方法包括 forEach 遍历、count 统计个数、filter 按条件过滤、limit 取前 n 个元素、skip 跳过前 n 个元素、map 映射加工、concat 合并 stream 流等。

  9. 日期:增强了日期和时间 API,新的 java.time 包主要包含了处理日期、时间、日期/时间、时区、时刻和时钟等操作。

java.io 包下有哪些流?

主要分为字符流和字节流,字符流一般用于文本文件,字节流一般用于图像或其他文件。

字符流包括了字符输入流 Reader 和字符输出流 Writer,字节流包括了字节输入流 InputStream 和字节输出流 OutputStream。字符流和字节流都有对应的缓冲流,字节流也可以包装为字符流,缓冲流带有一个 8KB 的缓冲数组,可以提高流的读写效率。除了缓冲流外还有过滤流 FilterReader、字符数组流 CharArrayReader、字节数组流 ByteArrayInputStream、文件流 FileInputStream 等。

序列化和反序列化是什么?

Java 对象 JVM 退出时会全部销毁,如果需要将对象及状态持久化,就要通过序列化实现,将内存中的对象保存在二进制流中,需要时再将二进制流反序列化为对象。对象序列化保存的是对象的状态,因此属于类属性的静态变量不会被序列化。

序列化机制允许将实现序列化的Java对象转换为字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以达到以后恢复成原来的对象的目的。序列化机制使得对象可以脱离程序的运行而独立存在。

常见的序列化有五种:

  1. Java 原生序列化

实现 Serializabale 接口,Java 序列化保留了对象类的元数据(如类、成员变量、继承类信息)以及对象数据,兼容性最好,但不支持跨语言,性能一般。序列化和反序列化必须保持序列化 ID 的一致,一般使用 private static final long serialVersionUID 定义序列化 ID,如果不设置编译器会根据类的内部实现自动生成该值。如果是兼容升级不应该修改序列化 ID,防止出错,如果是不兼容升级则需要修改。

  1. Hessian 序列化

Hessian 序列化是一种支持动态类型、跨语言、基于对象传输的网络协议。Java 对象序列化的二进制流可以被其它语言反序列化。Hessian 协议的特性:

① 自描述序列化类型,不依赖外部描述文件,用一个字节表示常用基础类型,极大缩短二进制流。
② 语言无关,支持脚本语言。
③ 协议简单,比Java 原生序列化高效。

Hessian 会把复杂对象所有属性存储在一个 Map 中序列化,当父类和子类存在同名成员变量时会先序列化子类再序列化父类,因此子类值会被父类覆盖。

  1. JSON 序列化

JSON 序列化就是将数据对象转换为 JSON 字符串,在序列化过程中抛弃了类型信息,所以反序列化时只有提供类型信息才能准确进行。相比前两种方式可读性更好,方便调试。

序列化通常会使用网络传输对象,而对象中往往有敏感数据,容易遭受攻击,Jacksonfastjson 等都出现过反序列化漏洞,因此不需要进行序列化的敏感属性传输时应加上 transient 关键字。transient 的作用就是把变量生命周期仅限于内存而不会写到磁盘里持久化,变量会被设为对应数据类型的零值。

  1. Kyro序列化
    序列化之后的占用空间,kryo略低于protostuff

  2. Protobuf序列化
    序列化和反序列化的耗时,都是protostuff优于kyro

protobuf为什么快?

  • 事先跟接收端约定好有字段(协议),直接将value拼在了一起,舍去了不必要的冗余字符,压缩空间。
  • 每个字段我们都用tag|value的方式来存储的,在tag当中记录两种信息,一个是value对应的字段的编号,另一个是value的数据类型(tag使用二进制进行存储,一般只会占据一个字节)。
  • 定义了Varint这种数据类型,可以以不同的长度来存储整数,将数据进一步的进行了压缩。
  • 每个字段分成三部分:tag|leg|value,其中的leg记录了字符串的长度,从leg后截取leg个字节的数据作为value。

总结:
在这里插入图片描述
如果对空间没有极其苛刻的要求,protostuff也许是最佳选择。protostuff相比于kyro还有一个额外的好处,就是如果序列化之后,反序列化之前这段时间内,java class增加了字段(这在实际业务中是无法避免的事情),kyro就废了。但是protostuff只要保证新字段添加在类的最后,而且用的是sun系列的JDK, 是可以正常使用的

设计模式七大原则

数据结构 + 算法 = 程序

数据结构又是什么呢?就是在研究数据以及数据之间的关系和操作。在Java中数据就体现为对象。所以我们要学习的也就是对象以及对象之间的关系和对象相关的操作。

在这里插入图片描述

  1. 开闭原则(Open Closed Principle,OCP)

在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。

当软件需要变化时,尽量通过扩展软件实体的行为(给抽象类或接口多加一个实现类)来实现变化,而不是通过修改已有的(业务)代码来实现变化

  1. 单一职责原则

核心就是解耦和增强内聚性。如果一个类承担的职责过多,就等于把这些职责耦合在一起了。一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当发生变化时,设计会遭受到意想不到的破坏。

  1. 里氏代换原则(Liskov Substitution Principle,LSP)

是对"开-闭"原则的补充,即所有引用基类的地方必须能透明地使用其子类的对象。是实现开闭原则的基础,它告诉我们在设计程序的时候进可能使用基类进行对象的定义和引用,在运行时再决定基类的具体子类型。

  1. 依赖倒转原则(Dependency Inversion Principle,DIP)

程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。

  1. 接口隔离原则(Interface Segregation Principle,ISP)

客户端不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上。简单来说就是建立单一的接口,不要建立臃肿庞大的接口。比如spring-data-redis里,RedisTemplate中持有一些列的基类,分别是ValueOperations(处理K-V)、ListOperations(处理Hash)、SetOperations(处理集合)等等。

  1. 合成/聚合复用原则

在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向内部持有的这些对象的委派达到复用已有功能的目的,而不是通过继承来获得已有的功能。

  1. 迪米特法则

核心观点就是类间解耦,也就降低类之间的耦合,只有类处于弱耦合状态,类的复用率才会提高。所谓降低类间耦合,实际上就是尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。

常用的设计模式,java中的一些类的设计是如何体现设计模式的?

常用的有:单例模式,工厂模式,观察者模式,适配器模式,装饰器模式,迭代模式,代理模式,责任链模式

1)单例模式

定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

例子:spring中的单例模式提供了全局的访问点BeanFactory。Spring的依赖注入(包括lazy-init方式)都是发生在AbstractBeanFactory的getBean里。getBean的doGetBean方法调用getSingleton进行bean的创建。

饱汉式、饿汉式、静态内部类、双重检验

只给一个饿汉式的例子:

public class SingleObject {

   //创建 SingleObject 的一个对象
   private static SingleObject instance = new SingleObject();

   //让构造函数为 private,这样该类就不会被实例化
   private SingleObject(){}

   //获取唯一可用的对象
   public static SingleObject getInstance(){
      return instance;
   }

}

2)工厂模式

简单工厂模式:一个抽象的接口,多个抽象接口的实现类,一个工厂类,用来实例化抽象的接口。

// 抽象产品类
abstract class Car {
   public void run();

   public void stop();
}

// 具体实现类
class Benz implements Car {
   public void run() {
       System.out.println("Benz开始启动了。。。。。");
   }

   public void stop() {
       System.out.println("Benz停车了。。。。。");
   }
}

class Ford implements Car {
   public void run() {
       System.out.println("Ford开始启动了。。。");
   }

   public void stop() {
       System.out.println("Ford停车了。。。。");
   }
}

// 工厂类
class Factory {
   public static Car getCarInstance(String type) {
       Car c = null;
       if ("Benz".equals(type)) {
           c = new Benz();
       }
       if ("Ford".equals(type)) {
           c = new Ford();
       }
       return c;
   }
}

public class Test {

   public static void main(String[] args) {
       Car c = Factory.getCarInstance("Benz");
       if (c != null) {
           c.run();
           c.stop();
       } else {
           System.out.println("造不了这种汽车。。。");
       }

   }

}

工厂方法模式有一个问题就是,类的创建依赖工厂类,也就是说,如果想要拓展程序,必须对工厂类进行修改,这违背了闭开原则。

方法:不再是由一个工厂类去实例化具体的产品,而是由抽象工厂的子类去实例化产品
如果要新增发送微信,则只需做一个实现类,实现Sender接口,同时做一个工厂类,实现Provider接口,就OK了,无需去改动现成的代码。这样做,拓展性较好!

例子:BeanFactory。Spring中的BeanFactory就是简单工厂模式的体现,根据传入一个唯一的标识来获得Bean对象。

3)观察者模式

定义:一个对象(subject)被其他多个对象(observer)所依赖。则当一个对象变化时,发出通知,其它依赖该对象的对象都会收到通知,并且随着变化。比如很多人订阅微信公众号,该公众号有更新文章时,自动通知每个订阅的用户。

例子:spring的事件驱动模型使用的是观察者模式 ,Spring中Observer模式常用的地方是listener的实现。

4) 适配器模式

将两种完全不同的事物联系到一起,就像现实生活中的变压器。假设一个手机充电器需要的电压是20V,但是正常的电压是220V,这时候就需要一个变压器,将220V的电压转换成20V的电压,这样,变压器就将20V的电压和手机联系起来了。

public class Test {
   public static void main(String[] args) {
       Phone phone = new Phone();
       VoltageAdapter adapter = new VoltageAdapter();
       phone.setAdapter(adapter);
       phone.charge();
   }
}

// 手机类
class Phone {

   public static final int V = 220;// 正常电压220v,是一个常量

   private VoltageAdapter adapter;

   // 充电
   public void charge() {
       adapter.changeVoltage();
   }

   public void setAdapter(VoltageAdapter adapter) {
       this.adapter = adapter;
   }
}

// 变压器
class VoltageAdapter {
   // 改变电压的功能
   public void changeVoltage() {
       System.out.println("正在充电...");
       System.out.println("原始电压:" + Phone.V + "V");
       System.out.println("经过变压器转换之后的电压:" + (Phone.V - 200) + "V");
   }
}

例子:SpringMVC中的适配器HandlerAdatper。
HandlerAdatper根据Handler规则执行不同的Handler。

5)装饰器模式

对已有的业务逻辑进一步的封装,使其增加额外的功能,如Java中的IO流就使用了装饰者模式,用户在使用的时候,可以任意组装,达到自己想要的效果。

public class Test {
   public static void main(String[] args) {
       Food food = new Bread(new Vegetable(new Cream(new Food("香肠"))));
       System.out.println(food.make());
   }
}

6)代理模式

为其他对象提供一种代理以控制对这个对象的访问。 从结构上来看和Decorator模式类似,但Proxy是控制,更像是一种对功能的限制,而Decorator是增加职责。

spring的Proxy模式在aop中有体现,比如JdkDynamicAopProxyCglib2AopProxy

举报

相关推荐

0 条评论