java基础(含JUC):论后端工程师的个人修为

备战秋招面试,微信搜索公众号【TechGuide】关注更多新鲜好文和互联网大厂的笔经面经。
作者@TechGuide【全网同名】
点赞再看,养成习惯,您动动手指对原创作者意义非凡

当你的才华还撑不起你的野心时,你应该静下心去学习 。点赞再看,养成习惯

文章目录

  • 前言
  • 正文
    • 1.设计模式七大原则
    • 2.有序集合有哪些?线程安全的集合有哪些?
    • 3.HashMap、ConcurrentHashMap底层原理
    • 4.ArrayList底层原理,ArrayList和Vector的区别,LinkedList和ArrayList的区别
    • 5.String,StringBuffer,StringBuilder的区别 扩展:String不可变有什么好处?
    • 6.迭代器是做什么的?迭代器的fail-fast机制了解吗?主要为了解决什么问题?
    • 7.ConcurrentHashMap的底层数据结构
    • 8.抽象类与接口的区别
    • 9.synchronized和Lock分别在什么情况下使用,使用的理由。synchronized锁膨胀过程。
    • 10.java中各种锁的实现和使用场景 (乐观锁悲观锁 )
    • 11.可重入锁和非可重入锁的区别
    • 12.线程池的七个参数,线程池的好处
    • 13.java中有哪些常用的线程池
    • 14.常用的设计模式,java中的一些类的设计是如何体现设计模式的?
    • 15.什么是CAS算法?CAS底层做了什么?CAS可能产生什么问题?
    • 16.知道AQS框架吗?说一下AQS的底层原理吧( 重点掌握AQS对于线程排队的过程和存储线程的数据结构)
    • 17.如果一个线程要在其他几个线程运行完之后运行,有什么办法? (CountDownLatch或Phaser,重点掌握底层数据结构)
    • 18.多线程在运行过程中抛出异常怎么捕获? (Future或回调函数)
    • 19.如果想把两个线程的结果拿到进行下一步操作怎么做 (可以采用CompletableFuture,1.8的新API)
    • 20.如何保证线程顺序执行
    • 21.sleep和wait的区别
    • 22.ThreadLocal可以解决什么问题?具体的应用场景? (可以用ThreadLocal存储当前线程的数据库连接,分布式链路追踪中可以存放当前线程对应的链路信息)
    • 23.ReenTrantLock中的condition有什么作用?condition的await和signal和Object的wait和notify有什么区别?
    • 24.volatile主要解决了什么问题?
    • 25.反射的原理,有什么应用
    • 26.动态代理
    • 27.泛型(可以用Object代替吗?作用,泛型擦除)
    • 28.JDK8 新特性有哪些?
    • 29.java.io 包下有哪些流?
    • 30.序列化和反序列化是什么?
    • 31.static详解
  • 后记

前言

java语言因为它强大的系统性、严谨性成为了后端工程师的首选工具。工欲善其事,必先利其器,如果对自己日常使用的”趁手武器“的特性都不够了解,使用的都不够熟练的话,又要怎么搭建起复杂的后端架构呢?所以,成为后端工程师的第一课,我们从熟悉java开始。
java基础(含JUC):论后端工程师的个人修为_第1张图片

正文

在开始梳理之前,带着问题去复习知识,往往是最行之有效的学习方法之一,因为这样很容易让你把握住重点,并且试图在阅读中找出关联,融会贯通,先思考一下,上面目录中这亿点点问题你是否已经理解透彻。

如果对于上面的问题,你都可以侃侃而谈的话,说明你对java基础掌握的比较扎实,确实也没有读下去的必要了。但是如果没有的话,欢迎和我一起梳理一遍这些知识点,希望你有所收获。

刨根问底

1.设计模式七大原则

谈谈你对面向对象的理解。面向对象的设计模式的七个基本原则,能说出分别的目的吗?扩展:结合Spring框架谈面向对象设计的原则

数据结构 + 算法 = 程序

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

java基础(含JUC):论后端工程师的个人修为_第2张图片

  • 开闭原则(Open Closed Principle,OCP):在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。当软件需要变化时,尽量通过扩展软件实体的行为(给抽象类或接口多加一个实现类)来实现变化,而不是通过修改已有的(业务)代码来实现变化

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

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

  • 依赖倒转原则(Dependency Inversion Principle,DIP)的定义:程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。

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

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

合成/聚合复用与继承优劣:
继承复用破坏包装,因为继承将基类的实现细节暴露给派生类,基类的内部细节通常对子类来说是可见的,这种复用也称为"白箱复用"。这里有一个明显的问题是:派生类继承自基类,如果基类的实现发生改变,将会影响到所有派生类的实现;如果从基类继承而来的实现是静态的,不可能在运行时发生改变,不够灵活。
由于合成或聚合关系可以将已有的对象,一般叫成员对象,纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为黑箱复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成/聚合复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。

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

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

1) 有序集合:
java基础(含JUC):论后端工程师的个人修为_第3张图片
CollectionMap接口常用的实现类:

父接口 子接口 实现类
Collection List Vector
Collection List ArrayList
Collection List LinkedList
Collection Set HashSet (HashMap实现)
Collection Set HashSet<–LinkedHashSet
Collection Set SortedSet<–TreeSet(SortedMap实现)
Map AbstractMap HashMap
Map - HashTable
Map SortedMap TreeMap
Map - ConcurrentHashMap/LinkedHashMap

2) Vector和Hashtable线程安全已淘汰:

VectorArrayList类似,是长度可变的数组,与ArrayList不同的是,Vector是线程安全的,利用synchronized锁住方法。

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>());

4) java.util.concurrent包中的集合:

  • ConcurrentHashMapHashTable都是线程安全的集合,它们的不同主要是加锁粒度上的不同。HashTable的加锁方法是给每个方法加上synchronized关键字,这样锁住的是整个Table对象。而ConcurrentHashMap是更细粒度的加锁。在JDK1.8之前,ConcurrentHashMap加的是分段锁,也就是Segment锁,每个Segment含有整个table的一部分,这样不同分段之间的并发操作就互不影响。
    JDK1.8对此做了进一步的改进,它取消了Segment字段,直接在table元素(桶)上加锁,实现对每一个桶内全部entry进行加锁,进一步减小了并发冲突的概率。(具体原理下面会详细论述)

  • 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();
    }
    }

5)HashTable和ConcurrentHashMap

  • HashTable
  1. 底层数组+链表实现,无论key还是value都不能为null
  2. 线程安全,实现线程安全的方式是,在修改数据时锁住整个HashTable,效率低,ConcurrentHashMap做了相关优化
  3. 初始size为11,扩容:newsize = olesize*2+1
  4. 计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length
  • HashMap
  1. 底层数组+链表/红黑树实现,可以存储null键和null值,线程不安全
  2. 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂
  3. 扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入
  4. 插入元素后才判断该不该扩容,有可能无效扩容(插入后如果扩容,如果没有再次插入,就会产生无效扩容)
  5. 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀
  6. 计算index方法:index = hash & (tab.length – 1)

加载因子:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小。
当hash表中的负载因子达到指定的“负载极限”时,hash表会自动成倍地增加容量(桶的数量),并将原有的对象重新分配,放入新的桶内,这称为rehashing

3.HashMap、ConcurrentHashMap底层原理

HashMap底层原理解析:(扩展:jdk1.7和1.8有什么改进?为什么长度大于8转换成红黑树?这个8怎么来的?)

在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,如果想将链表装换为红黑树的时候,不仅是当前桶有8个值,这是第一个判断条件,也就是说当满足这个条件时会调用treeifyBin()这个方法,但是这个方法的第一步就是判断当前的map集合中是否总数量达到了64个,如果没有达到的话,直接进行扩容。这样大大减少了查找时间。

加载因子(默认0.75):为什么需要使用加载因子,为什么需要扩容呢

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

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值也要相同
             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;来计算将要插入的元素在数组中的索引位置。这样做有什么妙处?
1、保证不会发生数组越界
首先我们要知道,在HashMap和ConcurrentHashMap中,数组的长度按规定一定是2的幂(2的n次方)。因此,数组的长度的二进制形式是:10000…000, 1后面有一堆0。那么tab.length - 1 的二进制形式就是01111…111, 0后面有一堆1。最高位是0, 和hash值相“与”,结果值一定不会比数组的长度值大,因此也就不会发生数组越界。
2、保证元素尽可能的均匀分布
由上边的分析可知,tab.length一定是一个偶数,tab.length - 1一定是一个奇数。如果数组长度是奇数呢。减去1后(tab.length - 1)就是偶数了,偶数对应的二进制最低位一定是 0,例如14二进制1110。对上面两个数字分别“与”运算,得到1000和1000。结果都是一样的值。那么,哈希值8和9的元素都被存储在数组同一个index位置的链表中。在操作的时候,链表中的元素越多,效率越低,因为要不停的对链表循环比较。
并且,如果数组长度不是2的次幂,也就是低位不是全为1此时,要使得index为一特定值,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。
所以,一定要使哈希均匀分布,尽量减少哈希冲突,提高效率。

(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;
    }
  1. 判断键值对数组tab[]是否为空或为null,否则以默认大小resize()
  2. 根据键值key计算hash值得到插入的数组索引i,如果tab[i]==null,直接新建节点添加(线程安全问题),否则转入3
  3. 判断当前数组中处理hash冲突的方式为链表还是红黑树 (check第一个节点类型即可),分别处理

(3)resize()方法:

...		//Note that here we create a new table for transfer
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
 table = newTab;//把新表赋值给table
 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倍大小。

jdk8中构造新表的方法【jdk7中对每个元素都重新计算一遍index并移动】

  • 如果e后边有链表,到这里表示e后面带着个单链表,需要遍历单链表,将每个结点重新计算在新表的位置,并进行搬运。
  • 新表是旧表的两倍容量,实例上就把单链表拆分为两队,e.hash&oldCap为偶数一队(lo),e.hash&oldCap为奇数一对(hi)。(思考一下这里为什么得到的是一个数在看下面)
  • lo队不为null,放在新表原位置,hi队不为null,放在新表j+oldCap位置
    java基础(含JUC):论后端工程师的个人修为_第4张图片
    说明:
    我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,而且,这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
    【扩充】

hash()函数:

有java7的4次4位右移异或变成了仅一次16位右移移位。
"扰动函数"
java基础(含JUC):论后端工程师的个人修为_第5张图片
混合原始哈希码的高16位和低十六位,低位掺杂了高位的部分特征,变相保留了高位,但是又只利用了有限的位数与(len-1)相与。
java基础(含JUC):论后端工程师的个人修为_第6张图片
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);
}

生成下标的全过程:
在这里插入图片描述
【拓展】

重写equals方法需同时重写hashCode方法
不重写hashcode()的话,equals()为true的两个对象的hashcode:hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)
在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。

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挂起
                 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都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

HashMap与Hashtable的区别:

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

ConcurrentHashMap简单介绍(后文详述):

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

ConcurrentHashMap比HashMap多出了一个类Segment,而Segment是一个可重入锁。

ConcurrentHashMap是使用了锁分段技术来保证线程安全的。

锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

ConcurrentHashMap提供了与Hashtable和SynchronizedMap不同的锁机制。Hashtable中采用的锁机制是一次锁住整个hash表,从而在同一时刻只能由一个线程对其进行操作;而ConcurrentHashMap中则是一次锁住一个桶。

ConcurrentHashMap默认将hash表分为16个桶,诸如get、put、remove等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有16个写线程执行,并发性能的提升是显而易见的。

  1. 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
  2. Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
  3. 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
  4. 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容

4.ArrayList底层原理,ArrayList和Vector的区别,LinkedList和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);
    }

ArrayList和Vector的区别:
java基础(含JUC):论后端工程师的个人修为_第7张图片
1、Vector是线程安全的,ArrayList不是线程安全的。

2、ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍
在这里插入图片描述
java基础(含JUC):论后端工程师的个人修为_第8张图片
LinkedList和ArrayList的区别:

  1. ArrayList是实现了基于动态数组的数据结构,而LinkedList是基于链表的数据结构;

  2. 对于随机访问get和set,ArrayList要优于LinkedList,因为LinkedList要移动指针;

  3. 对于添加和删除操作add和remove,一般大家都会说LinkedList要比ArrayList快,因为ArrayList要移动数据。

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

String 字符串常量
StringBuffer 字符串变量(线程安全)
StringBuilder 字符串变量(非线程安全)

关于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修饰的字符数组实现字符串存储的。

final修饰:

  1. 修饰类的时候,说明该类不能被继承
  2. 修饰方法的时候,说明该方法不能被重写
  3. 修饰成员变量时,有两种情况:
    - 如果修饰的是基本类型,说明这个变量所代表的数值永不能变
    - 如果修饰的是引用类型,该变量的引用不能变

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

关于字符串常量:

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对象。
    java基础(含JUC):论后端工程师的个人修为_第9张图片
    其实 final 修饰的仅仅只是 value 这个引用,你无法再将 value 指向其他内存地址,而且在 String 中有许多对字符串进行操作的函数,例如 substring concat replace replaceAll 等等,这些函数是否会修改类中的 value 域呢?答案是否定的。每一步操作都不会对 value产生任何影响。首先使用 Arrays.copyOf() 方法来获得 value 的拷贝,最后重新 new 一个String对象作为返回值。

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 堆区分开。

String不可变类的优点:

  1. 不可变类比较简单。
  2. 不可变对象本质上是线程安全的,它们不要求同步。不可变对象可以被自由地共享
  3. 不仅可以共享不可变对象,甚至可以共享它们的内部信息。
  4. 不可变对象为其他对象提供了大量的构建。
  5. 不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象
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';

补充:
通过反射可以破坏 String 的不可变性。

6.迭代器是做什么的?迭代器的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等于4,expecctedmodcount等于3,fail!会抛出一个ConcurrentModificationException()。
            list.add("s");        
        }
    }
}

7.ConcurrentHashMap的底层数据结构

建议掌握put,get的全过程,如果能对rehash有深入的理解就更好了。扩展:为什么1.8放弃了分段锁,分段锁的优缺点以及1.8的改进【拓展】

java基础(含JUC):论后端工程师的个人修为_第10张图片
【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 操作时仍然需要加锁处理。

【JDK1.8】其中抛弃了原有的 Segment 分段锁(最大并发度受Segment的个数限制),而采用了get()用volatile、put()用 CAS + synchronized 来保证并发安全性。查询时,首先通过tabAt()方法找到key对应的Node链表或红黑树,然后遍历该结构便可以获取key对应的value值。其中,tabAt()方法主要通过Unsafe类的getObjectVolatile()方法获取value值,通过volatile读获取value值,可以保证value值的可见性,从而保证其是当前最新的值。

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) {	//锁住桶的头元素
            .....
}

put操作大致可分为以下几个步骤:

  1. 计算keyhash值,即调用speed()方法计算hash值;
  2. 获取hash值对应的Node节点位置,此时通过一个循环实现。有以下几种情况:
    1)如果table表为空,则首先进行初始化操作,初始化之后再次进入循环获取Node节点的位置;
    2)如果table不为空,但没有找到key对应的Node节点(无哈希冲突),则直接调用casTabAt()方法插入一个新节点,此时不用加锁;
    3)如果table不为空,且key对应的Node节点也不为空,但Node头结点的hash值为MOVED(-1),则表示需要扩容,此时调用helpTransfer()方法进行扩容;
    4)其他情况下,则直接向Node中插入一个新Node节点,此时**需要对这个Node链表或红黑树通过synchronized加锁。**
  3. 插入元素后,判断对应的Node结构是否需要改变结构,如果需要则调用treeifyBin()方法将Node链表升级为红黑树结构;
  4. 最后,调用addCount()方法记录table中元素的数量。

原理总结:
如果当前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个线程正在进行扩容。【详见下方链接】

【拓展1-看jdk7版本】【拓展2-看jdk8版本】

8.抽象类与接口的区别

  1. 从定义上看,接口是个集合,并不是类。类描述了属性和方法,而接口只包含方法(未实现的方法)。接口和抽象类一样不能被实例化,因为不是类。但是接口可以被实现(使用 implements 关键字)。实现某个接口的类必须在类中实现该接口的全部方法。虽然接口内的方法都是抽象的(和抽象方法很像,没有实现)但是不需要abstract关键字。

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

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

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

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

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

可重入锁:在执行对象中所有同步方法不用再次获得锁。synchronizedReentrantLock一个方法中的内嵌方法可以直接获取锁(state++)
可中断锁:在等待获取锁过程中可中断。Lock接口中的lockInterruptibly()方法就体现了Lock的可中断性。
公平锁: 按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利。synchronized是非公平锁,它无法保证等待的线程获取锁的顺序。对于ReentrantLock和ReentrantReadWriteLock,默认情况下是非公平锁,但是可以设置为公平锁。

读写锁:对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写。 ReadWriteLock就是读写锁,它是一个接口,ReentrantReadWriteLock实现了这个接口。
java基础(含JUC):论后端工程师的个人修为_第11张图片

Lock类:

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

总结:
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
6)性能上来说,在资源竞争不激烈的情形下,Lock性能稍微比synchronized差点(编译程序通常会尽可能的进行优化synchronized)。但是当同步非常激烈的时候,synchronized的性能一下子能下降好几十倍。而ReentrantLock确还能维持常态。原因是ReentrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。

synchronized锁膨胀机制

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

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

目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
java基础(含JUC):论后端工程师的个人修为_第12张图片
1)无锁(不等于不锁)
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

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

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

3)轻量级锁
是指当锁是偏向锁,被另外的线程所访问时,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

4)重量级锁
升级为重量级锁时,此时Mark Word中存储的是指向重量级Monitor锁的指针,此时等待锁的线程都会进入阻塞状态
在这里插入图片描述

10.java中各种锁的实现和使用场景 (乐观锁悲观锁 )

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

乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

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

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

可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。

各种锁总结:
java基础(含JUC):论后端工程师的个人修为_第13张图片
【拓展】

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

ThreadPoolExecutor类参数:
1.corePoolSize 核心线程数
2.maxPoolSize 最大线程数
3.keepAliveTime 生存时间
4.unit 时间单位
5.workQueue:workQueue:一个阻塞队列,用来存储等待执行的任务。(这个参数的选择也很重要,会对线程池的运行过程产生重大影响,一般来说,这里的阻塞队列有以下几种选择:ArrayBlockingQueue; LinkedBlockingQueue; SynchronousQueue,是一个不存储元素的阻塞队列)
6.threadFactory 线程工厂
7.handler 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务
【详解】

线程池按以下行为执行任务

1.当线程数小于核心线程数时,创建线程。
2.当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。
3.当线程数大于等于核心线程数,且任务队列已满
a. 若线程数小于最大线程数,创建线程
b. 若线程数等于最大线程数,抛出异常,拒绝任务

优点:
1、线程是稀缺资源,使用线程池可以减少创建和销毁线程的次数,每个工作线程都可以重复使用。
2、可以根据系统的承受能力,调整线程池中工作线程的数量,防止因为消耗过多内存导致服务器崩溃。

线程池状态:
java基础(含JUC):论后端工程师的个人修为_第14张图片
【拓展】
【线程池原理(直接看下半部分)】

13.java中有哪些常用的线程池

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

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

3)newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
4)newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

14.常用的设计模式,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());
   }
}

例子:在我们的项目中遇到这样一个问题:我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。我们以往在spring和hibernate框架中总是配置一个数据源,因而sessionFactory的dataSource属性总是指向这个数据源并且恒定不变,所有DAO在使用sessionFactory的时候都是通过这个数据源访问数据库。

但是现在,由于项目的需要,我们的DAO在访问sessionFactory的时候都不得不在多个数据源中不断切换,问题就出现了:如何让sessionFactory在执行数据持久化的时候,根据客户的需求能够动态切换不同的数据源?我们能不能在spring的框架下通过少量修改得到解决?是否有什么设计模式可以利用呢?

首先想到在spring的applicationContext中配置所有的dataSource。这些dataSource可能是各种不同类型的,比如不同的数据库:Oracle、SQL Server、MySQL等,也可能是不同的数据源:比如apache 提供的org.apache.commons.dbcp.BasicDataSource、spring提供的org.springframework.jndi.JndiObjectFactoryBean等。然后sessionFactory根据客户的每次请求,将dataSource属性设置成不同的数据源,以到达切换数据源的目的。

spring中用到的包装器模式在类名上有两种表现:一种是类名中含有Wrapper,另一种是类名中含有Decorator。基本上都是动态地给一个对象添加一些额外的职责。 【拓展】

6)代理模式

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

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

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

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

缺点:

  1. CPU可能开销较大

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

  1. 不能保证代码块的原子性

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

  1. ABA问题。

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

16.知道AQS框架吗?说一下AQS的底层原理吧( 重点掌握AQS对于线程排队的过程和存储线程的数据结构)

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

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

如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个AQS机制是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

java基础(含JUC):论后端工程师的个人修为_第15张图片
AQS 定义了两种资源共享方式:
1.Exclusive:独占,只有一个线程能执行,如ReentrantLock
2.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。【拓展】

17.如果一个线程要在其他几个线程运行完之后运行,有什么办法? (CountDownLatch或Phaser,重点掌握底层数据结构)

CountDownLatch:
利用它可以实现类似计数器的功能。比如有一个任务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为止。

原理:
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阻塞队列里的节点线程全部唤醒。
【拓展】

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

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

18.多线程在运行过程中抛出异常怎么捕获? (Future或回调函数)

背景:
Thread类和Runnable接口,在执行完任务之后无法获取执行结果。如果需要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦。

多线程运行不能按照顺序执行过程中捕获异常的方式来处理异常,异常会被直接抛出到控制台(由于线程的本质,使得你不能捕获从线程中逃逸的异常。一旦异常逃逸出任务的run方法,它就会向外传播到控制台,除非你采用特殊的形式捕获这种异常。)

在java中要捕捉多线程产生的异常,需要自定义异常处理器,并设定到对应的线程工厂中
第一步:定义符合线程异常处理器规范的“异常处理器”
实现Thread.UncaughtExceptionHandler规范
第二步:定义线程工厂
线程工厂用来将任务附着给线程,并给该线程绑定一个异常处理器

Thread t = new Thread(r);
t.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());//设定线程工厂的异常处理器
System.out.println("eh="+t.getUncaughtExceptionHandler());

方法二:

关于FutureTask和Future、Callable【详细】
Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call()。
Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。Future提供了三种功能:1)判断任务是否完成;2)能够中断任务;3)能够获取任务执行结果。
因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了FutureTask。可以看出RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

19.如果想把两个线程的结果拿到进行下一步操作怎么做 (可以采用CompletableFuture,1.8的新API)

从Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。【详细】

//典型应用
CompletableFuture memberFuture = CompleteableFuture.runAsyc(...);
CompletableFuture.allof(memberFuture,wareFuture).get();

20.如何保证线程顺序执行

线程同步部分可查。

21.sleep和wait的区别

  1. sleep()Thread线程类的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复;wait()Object的方法,调用会放弃对象锁,进入等待队列,待调用notify()/notifyAll()唤醒指定的线程或者所有线程,才会进入锁池,不再次获得对象锁才会进入运行状态;
  2. sleep()方法正在执行的线程主动让出CPU(然后CPU就可以去执行其他任务),在sleep指定时间后CPU再回到该线程继续往下执行(注意:sleep方法只让出了CPU,而并不会释放同步资源锁);
    wait()方法则是指当前线程让自己暂时退让出同步资源锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用了notify()方法,之前调用wait()的线程才会解除wait状态,可以去参与竞争同步资源锁,进而得到执行。(注意:notify的作用相当于叫醒睡着的人,而并不会给他分配任务,就是说notify只是让之前调用wait的线程有权利重新参与线程的调度)
  3. sleep()方法可以在任何地方使用;wait()方法则只能在同步方法或同步块中使用;

22.ThreadLocal可以解决什么问题?具体的应用场景? (可以用ThreadLocal存储当前线程的数据库连接,分布式链路追踪中可以存放当前线程对应的链路信息)

ThreadLocal提供一个线程(Thread)局部变量,访问到某个变量的每一个线程都拥有自己的局部变量。ThreadLocal提供了线程的局部变量,每个线程都可以通过set()get()来对这个局部变量进行操作,但不会和其他线程的局部变量进行冲突,实现了线程的数据隔离。

应用场景:

1.用ThreadLocal存储当前线程的数据库连接,ThreadLocal能够实现当前线程的操作都是用同一个Connection,保证了事务!

//获取Connection对象
Connection connection = source.getConnection();

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

//返回Connection对象
return connection

2.浏览器就相当于我们的ThreadLocal,它仅仅会发送当前浏览器存在的Cookie(ThreadLocal的局部变量),不同的浏览器对Cookie是隔离的(Chrome,Opera,IE的Cookie是隔离的【在Chrome登陆了,在IE你也得重新登陆】)

ThreadLocal原理:

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

ThreadLocal可能存在的内存泄漏问题:java基础(含JUC):论后端工程师的个人修为_第16张图片
实线代表强引用,虚线代表的是弱引用,如果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()时创建线程的时候,为了复用,线程是不会结束的,【来源】

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

Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify()实现线程间的协作,相比使用Object的wait()、notify(),使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。
Condition可以实现多路通知功能,也就是在一个Lock对象里可以创建多个Condition(即对象监视器)实例,线程对象可以注册在指定的Condition中,从而可以有选择的进行线程通知,在调度线程上更加灵活。而synchronized就相当于整个Lock对象中只有一个单一的Condition对象,所有的线程都注册在这个对象上。线程开始notifyAll时,需要通知所有的WAITING线程,没有选择权,会有相当大的效率问题。

java基础(含JUC):论后端工程师的个人修为_第17张图片

与synchronized不同:
(1)与synchronized相比,ReentrantLock提供了更多,更加全面的功能,具备更强的扩展性。例如:时间锁等候,可中断锁等候,锁投票。
(2)ReentrantLock还提供了条件Condition,对线程的等待、唤醒操作更加详细和灵活,所以在多个条件变量和高度竞争锁的地方,ReentrantLock更加适合(下面会阐述Condition)。
(3)ReentrantLock提供了可轮询的锁请求。它会尝试着去获取锁,如果成功则继续,否则可以等到下次运行时处理,而synchronized则一旦进入锁请求要么成功,要么一直阻塞(锁升级之前),所以相比synchronized而言,ReentrantLock会不容易产生死锁些。
(4)ReentrantLock支持更加灵活的同步代码块,但是使用synchronized时,只能在同一个synchronized块结构中获取和释放。注:ReentrantLock的锁释放一定要在finally中处理,否则可能会产生严重的后果。
(5)ReentrantLock支持中断处理,且性能较synchronized会好些。

24.volatile主要解决了什么问题?

总线嗅探,高速缓存一致性协议,防止指令重排序。扩展:指令重排序带来的问题
【拓展】

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

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

2 . 禁止指令重排序
java基础(含JUC):论后端工程师的个人修为_第18张图片
在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。
如何处理原子性、可见性和有序性?
volatile跟可见性和有序性有关

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

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

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

从内存语义上来看

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

volatile底层原理:
lock前缀指令实际相当于一个内存屏障,内存屏障提供了以下功能:
1 . 重排序时不能把后面的指令重排序到内存屏障之前的位置
2 . 使得本CPU的Cache写入内存
3 . 写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。

应用场景:
修饰标记变量、单例模式里双重判断。

25.反射的原理,有什么应用

Java 反射机制在程序运行时,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性。这种动态的获取信息,以及动态调用对象的方法的功能称为 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数据库连接
    java基础(含JUC):论后端工程师的个人修为_第19张图片
    2.XML配置文件
    java基础(含JUC):论后端工程师的个人修为_第20张图片
    java基础(含JUC):论后端工程师的个人修为_第21张图片

26.动态代理

【reference】

核心:动态代理就是想办法,根据接口或目标对象,计算出代理类的字节码,然后再加载到JVM中使用。

通过实现接口的方式 -> JDK动态代理
通过继承类的方式 -> CGLIB动态代理
总结:无论是静态代理还是动态代理本质都是最终生成代理对象,区别在于静态代理对象需要人手动生成,而动态代理对象是运行时,JDK通过反射动态生成的代理类最终构造的对象

  • JDK原生动态代理

JDK动态代理主要涉及两个类:java.lang.reflect.Proxyjava.lang.reflect.InvocationHandlerjava基础(含JUC):论后端工程师的个人修为_第22张图片

import proxy.UserService;
import proxy.UserServiceImpl;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Client2 {
    public static void main(String[] args) throws IllegalAccessException, InstantiationException {
        // 设置变量可以保存动态代理类,默认名称以 $Proxy0 格式命名
        // System.getProperties().setProperty("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
        // 1. 创建被代理的对象,UserService接口的实现类
        UserServiceImpl userServiceImpl = new UserServiceImpl();
        // 2. 获取对应的 ClassLoader
        ClassLoader classLoader = userServiceImpl.getClass().getClassLoader();
        // 3. 获取所有接口的Class,这里的UserServiceImpl只实现了一个接口UserService,
        Class[] interfaces = userServiceImpl.getClass().getInterfaces();
        // 4. 创建一个将传给代理类的调用请求处理器,处理所有的代理对象上的方法调用
        //     这里创建的是一个自定义的日志处理器,须传入实际的执行对象 userServiceImpl
        InvocationHandler logHandler = new LogHandler(userServiceImpl);
        /*
		   5.根据上面提供的信息,创建代理对象 在这个过程中,
               a.JDK会通过根据传入的参数信息动态地在内存中创建和.class 文件等同的字节码
               b.然后根据相应的字节码转换成对应的class,
               c.然后调用newInstance()创建代理实例
		 */
        UserService proxy = (UserService) Proxy.newProxyInstance(classLoader, interfaces, logHandler);
        // 调用代理的方法
        proxy.select();
        proxy.update();     
        // 保存JDK动态代理生成的代理类,类名保存为 UserServiceProxy
        // ProxyUtils.generateClassFile(userServiceImpl.getClass(), "UserServiceProxy");
    }
}
  • 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();
    }
}

区别:

JDK动态代理:基于Java反射机制实现,必须要实现了接口的业务类才能用这种办法生成代理对象。
cglib动态代理:基于ASM机制实现,通过生成业务类的子类作为代理类。
JDK Proxy 的优势:
最小化依赖关系,减少依赖意味着简化开发和维护,JDK 本身的支持,可能比 cglib 更加可靠。
平滑进行 JDK 版本升级,而字节码类库通常需要进行更新以保证在新版 Java 上能够使用。
代码实现简单。
基于类似 cglib 框架的优势:
无需实现接口,达到代理类无侵入
只操作我们关心的类,而不必为其他相关类增加工作量。
高性能【拓展】

27.泛型(可以用Object代替吗?作用,泛型擦除)

【拓展】

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

泛型的好处

① 类型安全,放置什么出来就是什么,不存在 ClassCastException
② 提升可读性,编码阶段就显式知道泛型集合、泛型方法等处理的对象类型。
③ 代码重用,合并了同类型的处理代码。

为什么要使用泛型?不能用Object代替吗

java基础(含JUC):论后端工程师的个人修为_第23张图片
泛型擦除

泛型用于编译阶段,编译后的字节码文件不包含泛型类型信息,因为虚拟机没有泛型类型对象,所有对象都属于普通类。例如定义 ListList,在编译后都会变成 List

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

在这里插入图片描述

28.JDK8 新特性有哪些?

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

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

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

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

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

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

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

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

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

29.java.io 包下有哪些流?

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

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

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

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

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

常见的序列化有五种:

Java 原生序列化

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

Hessian 序列化

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

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

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

JSON 序列化

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

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

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

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

protobuf为什么快?

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

总结:
java基础(含JUC):论后端工程师的个人修为_第24张图片

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

31.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;
    }
}

注意:

  1. Java中的static关键字不会影响到变量或者方法的作用域。在Java中能够影响到访问权限的只有private、public、protected这几个关键字。
  2. 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的一切知识点【阅读版】【原帖】

后记

java基础知识远远不止上面这些,它涉及到很多的语法规则和使用规范,以至于想要掌握其中的每一个细节变得尤为困难,甚至可能有很多知识点在你日常工作中连验证的机会都没有,但这并不代表他们不重要,恰恰相反,java基础决定了你使用java这门语言作为生产力工具创造价值的上限。还有一点想说的是,不要把java基础当作考试知识点来背诵,打个比方,“你知道static有几种用法吗?”,这个问题一抛出来,脑子里不应该想打印bullet points一样,而是明白为什么引入这个关键字,以及引入它解决了什么样的问题,最好结合自己实际的项目使用经历来,这样会引发更多的思考。

记住,知识的积累是循序渐进的,学习的过程是螺旋上升的。

你可能感兴趣的:(秋招计算机知识点总结,java,jvm,多线程,spring,后端)