Java总结
seefly
seefly
发布于 2021-05-24 / 61 阅读 / 0 评论 / 0 点赞

Java总结

1、java基础

1.1 常见问题

  1. 自动拆箱装箱/缓存

    byte,short,int,long都有自己的缓存池,范围是-128127。当通过 Integer a = 123进行赋值的时候,会经过jvm优化使用它内部的缓存池。这样两个同样方式赋值的且在范围内的包装类型他们指向的是同一个对象。其次Boolean的缓存池只有内部的true和false两个,char的缓存池是0127。

  2. 关于char

    基本类型char使用两字节表示,意味着只能表示unicode在0~65535之间的字符,超出这个范围的字符没法表示,例如部分中文。如果给它赋值超过表示范围的unicode则会使用65535进行mod。

  3. 浮点数相等问题

    由于各种基本类型的数值都是由二进制来表示,那么十进制的0.1没法使用二进制来准确表示,所以这种情况下会造成精度丢失,解决的办法是使用BigDecimal.valueOf(string)方法或者new BigDecimal(string),为什么用string当参数?因为传入的浮点数本身就没法准确表示例如0.1这种数值,所以使用string来表示。

  4. == 和 equals

    一个是操作符,一个是方法。双等于号比较的是两个对象的内存地址是否指向同一个对象,而equals则是使用它自己的逻辑进行判断。默认的Object.equals方法是比较内存地址,我们通常对它和hashCode方法进行覆盖,实现自己的逻辑。

  5. 为什么重写equals的时候还要重写hashCode?

    因为这两个方法要保持一致性、传递性、自反性。比如A.equals(B) ==true的话,那么hashCode方法的返回值应该是相同的,我们通常在操作Map或者Set的时候才会重写这两个方法。

1.2 字符串

  1. 为什么字符串是不可变的?

    1. 不可变天然线程安全,可以在多线程中使用
    2. 字符串在当作参数传递时,比可变可以避免很多问题
    3. 字符串不可变可以使用 字符串常量池
    4. 如果字符串不可变的话那么只需要计算一次hash值缓存起来就行了。
  2. 字符串的数据结构

    1. java9之前都是使用final char[]数组来表示的,但是从java9开始使用byte数组来表示,这样做的目的是

      减少内存开销,例如一个字符a,之前需要两个字符表示,现在只需要一个字节

  3. new String("12")创建几个对象?

    如果在执行到这行代码之前,串池中已经有"12"的话,就只会创建一个字符串对象
     
    如果字符串常量池中没有这个字符串的话,会在运行到这段代码的时候在串池中创建一个"12"对象,然后在堆内存中使用new 关键字创建一个字符串对象,一共两个。常量池--->运行时常量池会将符号引用替换成地址引用,所以对于"12"字面量的符号引用,会在这个时候在串池中创建对象,让运行时常量池持有地址引用。
    
  4. StringBuilderStringBuffer的区别

    1. StringBuilder线程不安全,StringBuffer的相关api都加上了同步,是线程安全的。

1.3 容器

容器主要包括两种数据结构,Collection和Map,前者是存储对象的容器,后者则是维护键值对关系的容器。

1.3.1.Collection 集合

List

List长度可变,插入有序,元素可以重复。

  1. ArrayList

    底层由数组来实现,所以支持索引下标快速随机访问,但是在删除或者在指定位置插入元素的时候慢,因为之后的元素要向后位移。其次,ArrayList线程不安全。

    1. 扩容机制

      每次添加元素的时候首先会确定当前数组容量是否足够,如果发现不够的话会进行1.5倍的扩容,并使用Array.copyOf将原数组复制到新数组中,这个操作代价比较高,所以我们最好在一开始就根据实际情况指定好数组大小,避免频繁的扩容操作。

    2. 元素删除

      ArrayList的元素移除操作代价较高,因为它不像双向链表,只要改变指针就行,他需要在移除之后将后面的元素向前移动;

    3. 快速失败及如何在循环中移除元素

      快速失败的原因就是,在迭代一个ArrayList的时候直接使用下标的方式移除一个元素,造成内部的modCount和迭代器维护的modCount不一致导致的。避免这种异常的方式可以通过迭代器的remove方法来移除元素,或者不适用迭代器使用普通循环移除。或者使用java8里面的新api removeIf来操作,我更推荐使用这个,因为我们知道,在ArrayList中移除一个元素的代价比较大,这个新api则是对元素进行标记,最后再统一删除。`

  2. Vector

    ArrayList类似,但是是比较古老的List子类,线程安全。

    为什么不再推荐使用Vector?

    因为它在几乎每个单独的操作上都加上了同步,但是很显然我们并不需要这样。其次Vector的每次扩容都是容量翻倍。

  3. LinkedList

    基于双向链表实现,所以在插入删除元素的时候效率很高,因为只要改变前后两个元素的指针即可。由于使用双向链表实现,所以没法支持快速随机访问。同样的,他也是线程不安全的。

Set

Set接口表示一组元素唯一的集合,侧重点在元素唯一。

  1. HashSet

    HashSet无序、不重复,底层是通过HashMap来实现的。new一个HashSet其实就是创建了一个HashMap,但是它只用key,所有的value都是相同的默认值。

  2. LinkedHashSet

    具有插入顺序,是HashSet的子类,底层是使用LinkedHashMap来实现的。

  3. TreeSet

    有序,默认根据自然排序来确定元素顺序,底层使用TreeMap来实现的。

1.3.2 Map

Map是一组具有映射关系的集合,key唯一。

  1. HashMap

    java8之前,HashMap使用数组加链表的形式存储数据,其中链表是为了解决hash冲突,将冲突的元素挂在同一个链表上。在java8之后进行了优化,当数组元素数的数量大于64的时候,并且同一个桶上元素大于8的时候会将这个链表进行红黑树转换,同时在链表元素数量小于6的时候将红黑树转换为普通链表。

  2. LinkedHashMap

     `LinkedHashMap`继承自`HashMap`所以底层仍然是数组加链表及红黑树的存储形式,但是在此基础上它有加上了一个双向链表用来记录元素的插入顺序。
    
  3. Hashtable

    它是线程安全的,所以在效率上不如HashMap,当然这个类现在已经不推荐使用了。因为它会将整个hash表加锁,如果需要操作同步map,推荐使用ConcurrentHashMap

  4. HashMapHashTable的区别?

     他们之间的主要区别是前者线程不安全,后者是线程安全的。在底层的数据结构上也有区别,前者是数据+链表+红黑树实现,而后者则是数组+链表的形式,没有红黑树转换的过程。在扩容方式上前者在扩容的时候总是会将容量扩容到2的n次方大小,而后者则是2*n+1。还有就是 `HashMap`支持`null`key和`null`value.而后者则是都不支持。
    
  5. HashMap原理

     `HashMap`底层是数组+链表+红黑树的实现,通过链表的形式来解决hash碰撞。默认创建一个容量为16的哈希表,负载因子为0.75。首先说一下它的put方法,首先会调用元素的`hashCode`方法获得原始的哈希值,之后会通过扰动函数对原始的哈希值进行处理,这样做的目的是防止比较差劲的`hashCode`方法实现,能够让哈希值更加平均,之后通过哈希值与哈希表的长度取余获得当前元素在哈希表中的位置,如果这个位置上没有元素的话直接放上去,如果有的话则会形成一个链表,将这个新的元素放在链表头部,如果这个链表长度大于8并且当前哈希表的长度大于64的话会进行红黑树转换,这样做的目的是在发生大量哈希碰撞的时候能够提高查找效率。最后计算当前元素密度是否超过阈值,如果超过阈值的话就需要对他进行扩容。
    
  6. 说一下HashMap的扩容机制

     `HashMap`有一个容量阈值,这个阈值是通过当前哈希表的最大容量乘0.75得来的,如果哈希表的实际容量超过这个阈值的话会进行扩容,扩容为原来的2倍。具体步骤就是遍历哈希表里的每一个桶中的每一个元素,重新通过他们的哈希值和哈希表大小的余数计算当前元素在新哈希表中的下标,然后放进去。这一步是非常耗时的,而且有并发风险,并且在java8之前,在多线程环境下两个线程同时对一个`HashMap`进行扩容操作,可能会出现死循环。
    
  7. 为什么扩容大小是2的n次方?

     因为他使用的算法的原因,如果一个数为2的n次幂的话,那么这个数-1按位与上另一个数,结果就等于这个数和另一个数的取余操作,但是按位与比取余操作快的多,并且`HashMap`中的扩容操作需要大量的这种计算,所以这样做算是一种优化。
    
  8. 为什么HashMap是线程不安全的?哪些方法线程不安全?

     主要是因为它的`resize`扩容方法和`put`方法;
    
    • put方法,例如两个线程同时像一个HashMap中放元素,并且这两个元素的哈希值都是相同的,这样会得到同一个桶下标。但是线程A在计算完成准备存放元素的时候cpu时间用完了,轮到线程B,线程B直接把元素放进去了,这时候A再放的话会将原来的B给覆盖掉,造成数据丢失。
    • 扩容方法,这个方法在把老的哈希表的元素往新的哈希表迁移的时候可能会造成循环链表,导致无限循环最后cpu100%
  9. java8是如果解决死循环的?

     在`java8`中由于每次扩容为原来的2倍,所以对于同一个元素在新哈希表中的位置,要么保持不变,要么变为原来的下标+原来的哈希表的长度。所以可以将同一个链表上的元素迁移操作分为两个部分,一个是原地不动的,另一个是移动到原来下标+原哈希表长度位置。这样就解决了死循环的问题,但是还是线程不安全的。
    

2、JUC

2.1ConcurrentHashMap

  1. ConcurrentHashMap的存储结构

    Java7中对整个哈希表进行分段存储,每段的数据结构是Segment,它和一个继承了可重入锁,并维护着一个HashEntry数组,数组中的每个元素和原来的HashMap里面的桶是一个概念。但是如果想要对某个桶进行操作的话首先需要获得Segment锁。

    java8里面,又进行了优化,取消了分段锁,在put元素的时候如果当前桶位上没有直接使用CAS操作将这个元素放上去,如果当前桶位上有元素的话那么就会使用桶位上的头部元素进行同步关键字加锁操作。这样效率又提升了

  2. ConcurrentHashMapHashTable有什么区别?

    HashTableConcurrentHashMap都能代替HashMap在多线程环境下操作,那么他们的区别是什么呢?最主要的区别就是ConcurrnentHashMap的同步方式和HashTable相比较更优秀,因为后者是对整个哈希表进行枷锁,不论你操作哪一个桶,都是,并且get方法也同样被加了锁,而前者则是对哈希表采用分段锁的方式,这个在java8又进行了优化采用了对每个桶进行加锁。所以效率上非常高。

  3. ConcurrentHashMap的put方法

    简单点来说,首先判断keyvalue不能为null,判断当前的map有没有被初始化,没有的话去初始化,然后计算key的哈希值,根据哈希值找到在哈希表中的位置,如果没有冲突,就不加锁直接利用CAS把元素放上去。再判断当前哈希表是否正在扩容,如果在扩容,那么当前线程也加入到扩容任务中。如果有冲突或者CAS失败了,就是把链表头部加上锁把元素放到链表上,后面和HashMap一样,链表长度超过8并且哈希表元素数量超过64了会把链表转红黑树。最后一步更新当前哈希表大小,并计算是否需要扩容,需要扩容的话去扩容。

  4. ConcurrentHashMapget方法有锁吗?为什么?

    get方法不会加锁,因为它没有保证强一致性,只保证弱一致性。

  5. 它是强一致性的吗?为什么?

    他是弱一致性的,所以一个线程在修改另一个线程在迭代的时候可能迭代的是老的数据,同时size方法得到的大小可能也是不准确的。

  6. synchronizedMapConcurrentHashMap有什么区别?

    首先说性能,前者是对传入的map做了包装, 在多线程环境下操作它会锁着整张哈希表,而后者只会锁住单个桶。在高并发场景下性能会高很多。其次是应用场景了,前者这个api可以包装类似TreeMap,弥补了这种需求。

    其次前者和HashTable本质上没有什么区别,对于性能我也看过网上对他们的进行性能测试,表现都差不多,唯一的区别我觉得就是一个是新的api一个是老的,可能还有其他的细节方面的区别,我就不太清楚了。

3、IO

3.1 BIO

这是同步阻塞式io,调用阻塞式IO的方法后当前线程会阻塞在这里,直到系统将数据返回才会进行后面的操作,

3.2 NIO

这是同步非阻塞式IO,利用事件驱动的方式来避免阻塞线程,当线程发起NIO调用之后,不会再阻塞在这里,而是会告诉Selector说,当这个链接可用、可读、或者可写的时候通知我一下,我再来处理。而这个Selector调用系统底层的epoll或者IOCP方法检查有没有新的事件到来,这个操作是阻塞的,所以我们可以在一个线程中循环调用。

3.3 AIO

异步IO,是相对于NIO的升级,在NIO中利用事件注册机制避免线程死等可读可写,但是真正执行IO独写数据的时候,这一步还是需要同步进行的,在AIO中连这里也做成异步了。看了一些资料,AIO的应用并不广泛,之前netty也用过AIO,但是好像性能提升并不高,又换回来了

问题

  1. BIO,NIO,AIO 有什么区别?

    如上所述,BIO是阻塞式IO,读写操作都会被阻塞,所以对于每个独写请求都需要创建单独的线程去处理,所以适合并发不大的系统;AIO采用了同步非阻塞的方式,对于读写操作只有当真正有数据据可读以及可写的时候才会同步的去处理这些事情,不必傻等在哪里,可读可写只需要Selector线程告诉什我们就行了。AIO相较于NIO升级的是连真正读写数据这一步都是异步的,做到了真正的异步。

4、多线程

4.1 线程

  1. 进程和线程的区别是什么?

    简单点来说,进程就是应用程序执行的一个过程,在这个过程中它可能会创建出很多线程来帮助他完成工作,同时进程和进程之间他们的资源都是相互隔离的,没法共享。而同一个进程下的线程则可以共享堆、方法区等信息。

  2. 线程的几种状态

    img

    • NEW

      在一个Thread对象被创建,但是还没有调用start方法的时候,此时线程状态为New

    • RUNNABLE

      调用了start方法之后线程会从NEW状态转变为RUNNABLE状态,一旦从NEW状态改变就不会再变回去了。

      同时这个状态并不表示当前线程已经在执行了,它其实可以细分为两个状态:一个是READ,一个是RUNNING。只是java在面向对象抽象的时候把这两个状态合并成了一个。处于READ状态的线程表示所有需要运行的资源已经准备好了,但是还没有被分配cpu时间片来执行,处于RUNNING状态的线程则是真正的正在cpu上运行。

    • BLOCKED

      当线程对某个互斥资源进行锁竞争的时候,没有获取到,则会进入BLOCKED状态。

    • WAIT

      通常情况下需要多线程间相互协作的时候会出现这种状态,例如某个线程工作完毕需要另一个线程继续下面工作,此时这个线程会调用Object.wait方法进入等待状态,等待另一个线程唤醒;

    • TIMED_WAIT

      和刚才的WAIT状态不同,处于这一状态的线程不会无限的等待下去,它会有一个超时时间,在等待指定的时间没有被唤醒他会主动从这一状态跳出来;

    • TERMINATED

      当前线程的任务执行完毕之后会进入这个状态。

  3. 什么是上下文切换?

      一个`cpu`核心同一时间只能运行一个线程,而多线程编程中的线程数量往往是大于`cpu`核心数量的,为了能够让这些线程都有机会被运行,所以`cpu`采用时间片轮转的方式来执行他们,在一个时间片用完之后当前线程并不是直接退出,而是首先需要保存自己的状态,以便下次执行时恢复执行环境。这一个步骤就是上下文切换。
    
  4. sleep方法和wait方法有什么区别?

    sleep方法是Thread中定义的,而wait方法是Object中定义的。主要的区别就是sleep方法的执行不需要在同步代码块中,也就意味着它在执行的时候不会放弃自己手中的锁,而wait方法必须在同步代码块里执行,他一般用于多线程之间的协作。

  5. 什么情况下会发生中断异常?为什么?

    when does java thread sleep throw interrupted excetion

    当一个线程在等待或者休眠的时候,另一个线程将他中断,那么就会发生中断异常。例如常用的sleep方法会有一个必检异常。让你给出一个在当前线程等待期间发生异常该如何处理,一般情况下会将当前线程的中断标志位重置,然后退出。

  6. 说一说join方法

    这个方法属于Thread,一个线程调用另一个线程的join方法回导致调用者线程进入等待状态,直到另一个线程执行完毕它才会被唤醒。这个方法也会抛出中断异常,用于处理在等待期间线程被中断的问题。

  7. 什么是死锁?

    简单点来说就是线程之间的资源互斥造成的,举例来说两个线程彼此都需要对方手中的资源,但又都不放弃自己手中的资源,这样就造成了死锁。死锁的发生需要四个条件

  8. 死锁发生的条件?

    1. 互斥条件,就是一个资源同时只能被一个线程持有
    2. 请求与保持,由于请求别的互斥资源导致被BLOKED,但是还不放弃在自己手中的资源
    3. 不剥夺条件 ,对于自己手中的资源,除非自己方手,别人没法拿到
    4. 循环等待,多个线程首尾相连的等待彼此手中的资源
  9. 如何避免死锁?

    可以从下面三个方面入手,我们可以在执行前一次性获取所有需要的资源,没有获取到就不会执行。在持有互斥资源的情况下再进一步申请其他互斥资源没有得到的话,可以放弃自己手中的资源。最后我们可以按顺序申请资源,然后按相反的顺序示放资源。

  10. 线程间的通讯方式

    通过共享变量和等待唤醒机制来达到线程间通讯的目的,共享变量;

    使用共享变量达到线程间通讯的目的可以用volatile关键字修饰共享变量,这样在A线程修改之后B线程可以立即获取结果。

    等待通知的方式可以使用wait/notifyjoin、以及条件锁等方式。

  11. 并行和并发的区别

    并发就是同一个cpu利用时间片轮转的方式在一段时间内切换着执行多个任务。并行就是多个cpu在同一个时间点上同时执行多个任务。

4.2 Java内存模型之JMM

image-20210325142757828

  1. 简述一下java内存模型

    java中多个线程共享变量其实是通过主存来实现的,而jmm规定每个线程又不能直接操作主存中的变量,必须先拷贝一份副本到自己的工作空间内,操作完毕之后再写回主存中去。所以每个线程都有自己的工作空间,同时又共享主存。

  2. 什么是jmm的三大特性?

    1. 原子性

      原子性是指一个或者一系列操作要么都成功要么都失败。但由于内存模型的关系,对数据的修改可以分为三个步骤首先从主存中读取数据,然后修改,最后再写回,这种操作方式在多线程环境中并发修改它可能会出现丢失更新的现象。

    2. 可见性

      可见性是指如果线程修改了共享变量的值,那么其他线程应该立即直到这个修改。在jmm中是通过修改之后立即写回主存,其他线程在读取共享变量的时候先来主存中刷新一遍保证的。可以通过volatile synchrnized来保证可见性。

    3. 有序性

      就是成按照代码的先后顺序执行。由于jvm会进行指令重排序,进行优化,他只保证在单线程环境下运行结果和没有重排序是一直的。但是在先多线程环境下就会出现问题,我们通常使用volatile synchrnized来保证有序性。

4.3 volatile

  1. 说一下volatile 的原理

    volatile 关键字表示意思是容易变化的,一般用来修饰多线程间的共享变量,它能够方式指令重排序和保证共享变量的可见性。原理就对volatile 变量的写指令之后加入写屏障,对volatile 变量的读操作之前加入读屏障。写屏障保证在此之前的对共享变量的修改都要刷新到主存中,读屏障保证在此之后对共享变量的读取都是主存中最新的。

  2. volatile 能保证并发情况下的数据不紊乱吗?

    volatile不能保证数据的原子性,只能保证可见性可有序性,多个线程交替执行指令的话还是会出现丢失更新的现象。

  3. volatile 会导致什么问题?

    总线风暴 --> 缓存一致性协议 ---> MESI协议

  4. 双检查单例模式有没有并发风险?

    private static LazySingleton singleton = null;
    public static LazySingleton getInstance2() {
      if (singleton == null) {
        synchronized (LazySingleton.class) {
          if (singleton == null) {
            // 指令重排序可能会将构造方法指令放在给singleton之后
            // 可能会返回一个构造到一半的对象
            return singleton = new LazySingleton();
          }
        }
      }
      return singleton;
    }
    

    如果出现了指令重排序,那么可能会出现问题,下面的代码只能保证只有一个线程进入同步代码块调用构造方法。但是对于共享变量的赋值和调用构造方法是两个指令,如果他们发生了指令重排序,将赋值操作放在前面,构造方法的调用放在后面。另一个线程进来发现单例已经赋值会直接调用,此时构造方法可能只执行了一半,那么就会出现问题。这个时候可以利用valotile来修饰共享变量,利用它的写屏障禁止赋值指令重排序到构造函数的调用指令前,来避免发生问题。

4.4 synchronized

4.4.1 synchronized常见问题

  1. 说一下自己对synchronized的理解

    用来控制某些代码同一时间只能有一个线程执行,这样避免了在多线程环境下造成的数据紊乱。在早期的java里,使用同步关键字保证并发是一个比较重的操作。因为它依赖系统提供的互斥锁来实现并发控制,如果获取不到锁则会进入阻塞状态,这会发生内核态与用户态的转变。但是在java6之后,引入了很多机制来优化它,所以现在这个关键字的性能也比较不错了。

  2. 说一下synchronized可以用在哪些地方?

    synchronized可以用在实例方法,类方法,以及代码块上。当用在实例方法上的时候以当前实例为锁,当用在类方法上的时候就是以当前类的对象为锁。

  3. synchronized的底层原理是什么?

    synchronized修饰代码块的时候,会利用monitorentermonitorexit这两个指令,这两个指令分别加在代码块开始和结束的地方,monitorenter指令首先会尝试获取对象的锁,如果获取到则进入代码块执行,获取不到当前线程就会被阻塞等待,执行完毕之后会示放对象锁。当synchronized修饰方法的时候,会有一个ACC_SYNCRONIZED指令告诉JVM当前方法是一个同步方法,交给虚拟机来处理。

  4. 说一说JAVA6之后对synchronized的优化?

    java6之前,这个关键字的底层是依赖系统层面的监视器,如果获取不到锁的话会造成用户态和内核态的转换,成本很高。在这之后对synchronized做了很多优化,比如偏向锁、轻量锁、锁自旋、锁粗化、锁消除等。

  5. 锁的升级是什么?

    java6中,由于对同步关键字的优化,将锁分为了四种状态,分别是无锁、偏向锁、轻量锁、重量锁状态。这几种状态从轻到重依次升级,这个升级过程是单向的,只能升级不能降级。

4.4.2 synchronized底层原理

4.4.2.1 数据结构

  1. Java对象的对象头

    • Mark Word区域

      这片区域的结构是不固定的,根据锁的状态会发生改变。在无锁状态下会记录当前对象的哈希值,分代年龄,是否偏向锁,最后两位的含义是不会变的,代表当前锁的状态

      image-20210318105723449
    • Klazz 区域

      表示当前对象的类型指针,代表当前对象是那个类的实例。

  2. Monitor

    称为监视器或者管程,每一个对象都可以关联一个监视器。当一个锁重量级锁状态下时,取锁失败的线程会进入这个监视器的EntryList区域,同时监视器上的Owner区域指向当前持有锁的这个线程。

    image-20210318105641373

4.4.2.2 锁升级的过程

美团锁

一开始是无锁状态,第一个线程访问锁对象的时候判断当前锁的状态,发现是可偏向状态,此时他会利用`CAS`操作,将自己的线程ID放到`MARK WORD`里,后面再次进入这个同步代码块的时候就不再使用`CAS`操作了,只是简单的判断一下`MARK WORD`里面有没有自己的线程`id`就行了。后面再有线程进入的话发现当前锁对象已经偏其他线程了,如果被偏向的线程不再需要持有偏向锁的话会让锁对象重新偏向,如果还是需要的话这个时候偏向锁会升级为轻量锁。
image-20210319150147694
轻量锁认为虽然存在并发,但是他们之间的访问是交替的,也就是说还没有那么严重。例如当一个线程访问同步代码块时,发现锁对象后三位标记的是001的话,说明此时偏向锁已经关闭了,并且是无锁状态,如果要加锁请加轻量锁。此时这个线程就会在自己的栈内创建锁记录区域(lock record,包含一个指向锁对象的指针和指向这个锁记录的指针),同时利用`CAS`替换锁对象的`MARK WORD`,并把它放到锁记录中。在执行完毕之后会将`MARK WORD` 替换回去。如果在执行同步代码块的时候有线程进来或者获取锁的时候`CAS`失败,它首先会自旋一会,默认是20次,如果在此期间成功获得了锁,那就和前一个线程执行相同的步骤。如果失败了,那么此时轻量锁就会膨胀为重量级锁。如果CAS失败并且已经有一个线程在自旋了,直接膨胀到重量级锁。

轻量锁在线程自旋失败之后,会为锁对象创建监视器,里面并将监视器的指针放到`MARK_WORD`里,然后自己进入监视器中的`Entry_List`中阻塞。此时持有锁的线程准备释放锁的时候发现`MARK_WORD`已经被改了,他会按照里面的监视器指针找到监视器,然后唤醒阻塞队列中被阻塞的线程。

自旋锁和适应性自旋锁

有些时候某些同步代码块的执行时间非常短,这个时候会如果发生并发让线程自旋一会等待锁的释放效果可能比直接阻塞要好,所以引入了自旋锁的概念,适应性自旋锁就是如果上一次自旋成功了,那么说明自旋能获得锁的成功几率还是比较大的,那么我下一次自旋可以多自旋几次,也就是说自旋次数不再固定了。

4.4.2.3 常见概念

  1. 偏向锁撤销

    当调用锁对象的hashCode方法,或者wait/notify方法是会进行锁撤销,后者直接会让锁变成重量级锁。

  2. 锁消除

    简单点来说如果对某个方法加了锁,但是jvm发现这里并不会发生并发风险,所以会进行锁消除。

  3. 锁粗化

    如果jvm发现多个连续的操作都是进入同一个锁的话,那么会将这些锁合并成为一个锁。

  4. 批量重偏向

    例如多个锁对象偏向的都是一个线程,后面的线程再访问的时候一开始会将偏向锁升级为轻量锁,但是这样做超过20次的时候,将不再进行升级而是重新偏向。

4.5 happens before原则

从Java多线程可见性谈Happens-Before原则

happens-before俗解

happens befor

**happens before 是在多线程环境下共享变量操可见性规律的总结,比如什么情况其一个写操作的结果是对另一个读操作是可见的。注意,他不是描述实际执行的顺序。**
  1. volatile 规则

    对一个volatile共享变量的写操作happens before对它的读操作,那么这个操作结果对后者是可见的。(这一点是通过内存屏障来保证);

  2. 线程启动规则

    调用一个线程的start方法及它之前的方法 happens before当前线程的所有操作,所以start方法之前的所有操作,对这个线程都是可见的。

  3. 锁定规则

    对于M解锁之前的所有操作 happens beforeM加锁之后的操作,那么前者的操作结果对后者是可见的。(退出同步代码块之前会刷新工作空间到主存中)

  4. 程序顺序规则

    在一个线程内部,前面的动作都happens before后面的动作,这就意味着前面的操作结果对后面都是可见的。这是因为读的都是工作空间吧。(有前后依赖关系的指令不能进行指令重排序,所以前面的操作结果对后面都可见)

  5. 线程终止规则

    一个线程中的任何动作都happens before其他线程检测到这个线程已经结束。就是说如果一个线程发现另一个线程死亡了,那么这个死掉的线程的所有操作结果对这个线程都是可见的。(在线程结束前会把工作空间内的所有操作结果同步到主存)

  6. 中断规则

    对一个线程的中断方法的调用 happens before 该线程检测到中断事件的发生。就是说调用中断方法前的所有操作结果,在被调用线程检测到中断事件时都是可见的。

  7. 终结器规则

    调用对象的构造方法的结束happens before调用对象的终结器的结束。就是说在执行到对象终结器的时候,对象的构造方法肯定是执行完毕的。

  8. 传递性规则

    如果A happens before B,B happens before C,那么 A happens before C。

总结:

对于一个`volatile`共享变量,如果一个写操作先于另一个读操作,那么这个写操作的结果对这个读是可见的。如果对一个对象的解锁操作,先发生于对这个对象的加锁操作,那么解锁操作前的所有操作结果对加锁后都是可见的。在一个线程内前面发生的所有动作对后面都是可见的。构造函数的执行结束先发生于终结器的执行开始。如果一个线程检测到另一个线程死亡了,那么这个死亡的线程所有的操作结果对这个线程是可见的。一个线程调用了另一个线程的中断方法,在另一个线程收到中断信号的时候,那么线程调用中断方法之前的操作结果对这个线程都是可见的。如果A先发生于B,B先发生于C,那么可以知道A先发生于C,此时A的所有操作结果对C都是可见的。

4.6 CAS

  1. 什么是cas?

    就是比较和交换的意思,基于乐观锁思想对目标变量进行设值,并且是一个原子性操作,这个操作一共有三个参数,第一个是目标参数地址,第二个是期望值,第三个是需要更新的值。对一个目标参数使用CAS的结果要么成功,要么失败,并且这个操作是原子性的。我们一般配合volatile来在多线程环境下对共享变量做修改。虽然它无锁,但是如果竞争激烈的话失败的次数会比较高,反而不如加锁。

  2. ABA问题是什么?怎么解决的

    共享变量初始值为A,线程1将共享变量读到自己的工作空间,然后进行其他操作取了。此时线程2将这个共享变量修改为B,然后又给改回A。这个过程A是察觉不到的,然后线程1利用CAS也能操作成功。本质就是没法记录共享变量的变化。这个需要按照实际业务场景来,如果对变化不敏感,那就不需要。如果需要明确知道的话,可以使用版本号。

4.7 AQS

美团AQS

指南AQS

全称是 AbstractQueuedSynchronizer,阻塞式锁和一些同步化工具的框架,原理是基于CLH自旋锁的一个变种。juc包下面的一些同步工具类是基于他来构建的,里面提供了一些基本的模板方法,我们可以继承这个类构建自己的同步工具。它分为共享模式和独占模式,例如ReetrantLock就是独占模式,这种模式下,临界区资源只能有一个线程访问;而独占模式有 emaphoreCountDownLatchCyclicBarrierReadWriteLock,这种情况下临界区资源可以有多个线程同时访问。

4.7.1 CLH

参考1

参考2

它是一种基于链表的可扩展的自旋锁,具有先来先服务确保无饥饿的特性。申请线程在本地局部变量上自旋轮询获取前驱节点的状态,如果前驱释放了锁,那么就结束自旋。

4.7.2 AQS重要数据结构

  1. AQS构造

    • 其中status表示临界资源区的状态,使用volatile修饰,配置cas操作做更新。
    • head头指针,头指针本身是一个占位节点,不持有线程,它的下一个节点才是被阻塞的线程,它的作用就是唤醒下一个线程。
    • tail尾指针,指向阻塞队列中的尾节点
    • exclusiveOwnerThread指向持有临界区资源的线程
    image-20210325100133373
  2. Node节点构造

    • waitStatus,代表当前节点状态
    • prev,指向前一个node节点
    • next,指向后一个node节点
    • nextWaiter,用于条件锁中的单向链表
    Node节点构造

4.7.3 AQS的实现原理简述

主要是基于`CLH`的变体虚拟双向队列,它里面维护着一个`status`属性用来表示当前共享资源的状态,一个头节点指针和尾节点指针指向。对于每个请求共享资源的失败的线程都封装成一个节点保存在链表里面,当前一个节点里的线程把锁释放的时候就会唤醒下一个节点中的线程来取锁。

4.8 ReetrantLock

  `ReetrantLock`是可重入锁,内部的同步化器继承了`AQS`实现了公平和非公平两种同步方式,默认使用非公平的同步方式,并且提供了等待超时、可中断、条件锁等功能。

4.8.1 非公平锁

Sync extends AbstractQueuedSynchronizer

NonfairSync extends Sync

非公平锁
  1. 非公平锁加锁步骤

    在线程取锁的时候,方法会多次使用CAS操作去改变aqs中的state状态位,如果成功的话那么就说明取锁成功了,他会把自己设置为当前这个锁的一个独占线程,然后去执行自己的任务。如果失败的话会判断当前线程是不是这个锁的持有者,是的话就说明发生了锁重入,他会把status状态自增一下,然后执行自己的任务。如果不是的话,会创建一个节点,把当前线程放到这个节点里面,并将节点加入到aqs的虚拟双端队列的尾部,然后判断这个节点是不是头节点后的第一个节点,如果是的话那么会进行最后一次取锁尝试。还是不成功那么就是调用park方法将自己挂起。

  2. 可重入原理是什么?

    lock方法开始,它首先会去使用cas操作尝试改变state状态位,如果失败的话会进入acquire方法,再次尝试一次,没有成功会判断当前线程是不是已经拥有这个锁了,如果是的话就将state自增一下。在锁释放的时候会将它自减一下。他是通过这种方式来实现的锁重入。

  3. 可打断原理是什么?

    lock.lockInterruptibly(),我们知道被park阻塞的线程调用它的中断方法,并不会直接抛出中断异常,而是恢复运行。在这个方法里面,如果线程恢复了运行首先会检查自己的中断标志位,如果发现自己被其他线程中断了的话,那么直接就会抛出中断异常。

    但是在lock方法里,如果其他线程调用了它的中断方法,它醒来会检查自己的中断标志位,但是它并不会直接抛出异常而是设置一下flag表示自己被中断了,然后继续在阻塞队列中阻塞。

4.8.2 公平锁

和非公平锁的主要区别就是,它在取锁之前首先会检查自己有没有前驱节点,如果有前驱节点的话那么说明自己还没有资格获取这个锁,所以他会将自己挂起。而非公平锁在取锁之前不会去检查自己有没有前驱节点,而是直接去竞争锁。

4.8.3 条件锁

通过`Condition notFull = lock.newCondition();`在可重入锁上创建条件锁。

image-20210325103059734

	线程调用`await` 方法会释放自己手中的锁,并创建一个`Node`节点将当前线程放在这个节点里,然后加入`ConditionObject`的里,如果前面没有其他等待的节点,`ConditionObject`中的`head`和`tail`指针指向这个`Node`,后续如果有其他线程不满足条件的话会通过`Node`节点中的`nextWaiter`关联起来,通过条件变量挂起的线程创建的节点它的`waitState`等于-2,表示因为条件不满足而挂起的。唤醒的时候通过`signal`方法,这个方法会获取`ConditionObject`中的`head`指向的节点,将他加入到`AQS`的双端队列尾部,等待被唤醒。

4.9 线程池

  1. 为什么使用线程池?

    使用线程池是为了避免线程的频繁创建,让线程达到复用的目的,其次为了防止无限制的创建线程防止系统OOM,其次线程池还能够帮我们集中管理线程。

  2. 创建线程池的几个参数

    1. corePoolSize

      核心线程数,就是当前线程池里面有多少个核心线程来处理任务

    2. maximumPoolSize

      最大线程数,当核心线程数已满并且队列中的任务也满的时候会根据最大线程数来决定是否创建新的线程处理任务。

    3. keepAliveTime

      超出核心线程数的线程,在执行完成任务之后的存活时间。

    4. BlockingQueue

      阻塞队列,如果核心线程数满了,后面新加入的线程会先放入阻塞队列中,等待工作线程来执行。

  3. 线程池的几种状态

    1. RUNNING

      在这个状态下的线程池能够接收新的任务,并且还能处理队列中的任务。

    2. SHUTDOWN

      在这个状态下的线程池不再接收新的任务了,但是还会处理阻塞队列中的任务。

    3. STOP

      不在接收新的任务,也不再处理阻塞队列中的任务,而且还会把正在执行的任务给中断掉

    4. TIDYING

      所有任务已经执行完毕了,而且没有工作线程,同时还会调用 terminated()钩子方法

    5. TERMINATED

      terminated()方法执行完毕之后会进入这个状态。

  4. 线程池的拒绝策略

    如果阻塞队列是有界队列并且当前线程数已经达到最大线程数了,此时再给新的任务会执行拒绝策略。

    1. AbortPolicy

      这种拒绝策略会直接抛出异常,这是默认的拒绝策略。

    2. CallerRunsPolicy

      由调用者线程执行任务

    3. DiscardPolicy

      直接丢弃掉新的任务

    4. DiscardOldestPolicy

      会丢掉任务队列中最老的哪个任务。

  5. 阻塞队列有哪几种?

    1. 有限阻塞队列
      1. ArrayBlockingQueue
      2. SynchronousQueue
    2. 无限阻塞队列
      1. LinkedBlockingQueue
      2. PriorityBlockingQueue
      3. DelayQueue
  6. 线程池的扩展方法

    1. beforeExecute

      当一个任务执行之前的钩子方法,由任务执行者线程调用。

    2. afterExecutor

      一个任务执行完毕之后的钩子方法,由任务执行线程调用

    3. terminated

      所有任务被执行完毕,并且当前线程池处于TIDYING状态时,会调用这个方法,一般用来释放资源。

  7. jdk默认提供的几种线程池

    如果你不确定你的系统中要处理的任务的大小的话,使用这些线程池可能会造成OOM

    1. newSingleThreadExecutor

      单线程池,这个线程池的核心线程数和最大线程数都是1,使用无界阻塞队列,所以最多只有一个线程池处理任务。一般用来执行先进先出的任务,但是这个线程池会早晨OOM风险,因为它的阻塞队列是无界的。

    2. newFixedThreadPool

      固定线程池,可以指定核心线程数大小,所以可以控制最大并发量,无界阻塞队列可能会造成OOM

    3. newCachedThreadPool

      缓冲线程池,这个线程池的核心线程数是0,最大线程数可以自定义,拥有无界阻塞队列,所以这个线程池里面的线程都有一个生存时间,超过生存时间就会被回收掉。缺点是可能会无限的创建线程,造成OOM

    4. newScheduledThreadPool

      调度线程池,这个线程池使用延时阻塞队列,所以我们可以使用它进行延时执行任务。

  8. 如何优雅的停止线程池?

    首先需要知道,调用shutdown方法会线程池处于SHUTDOWN状态,在这个状态下的线程池不再接收新的任务,只会把任务队列中的任务执行完毕,之后会进入TIDYING状态。调用shutdownNow方法,会对当前线程池里正在执行任务的线程池执行中断方法,然后将任务队列中的任务当作返回值返回,并且不再执行。需要注意的是调用了线程的中断方法,并不意味着线程会处理这个中断信号。线程池给我们提供了一个terminated钩子方法,用它配合shutdown方法来优雅的停止线程池。

4.10 ThreadLoacl

5、jvm

一张图看懂JVM

5.1、 虚拟机内存模型

包含堆、java方法栈、本地方法栈、程序计数器、方法区;

5.1.1 堆

线程共享,几乎所有的实例都是在堆里面被创建,这块也是占用内存最多的地方,所以也是`gc`重点清理的对象。

5.1.2 方法区

线程共享的,方法区在虚拟机启动的时候就会创建,在虚拟机关闭时释放。这里存放了虚拟机加载的类的信息,比如方法信息、常量、类型信息等数据。在虚拟机规范中,这部分区域和堆在逻辑上是一个区域,但是实际上放在哪不做限制,并且在规范中说明这片区域的大小可以固定可以自动扩容以及压缩。使用`-XX:MetaspaceSize`参数调整初始方法区大小,使用`-XX:MaxMetaspaceSize`调整最大大小,在`windows`下默认初始大小`21m`,最大大小为-`1`即无限使用系统内存直到耗尽。

image-20210323215255945

  • 方法区和永久代及元空间之间的关系

    方法区是虚拟机规范中定义的一个逻辑区域,而永久代则是HotSpot对应的实现,其他虚拟机没有永久代这个概念。从java8开始,HotSpot里面的永久代就被替换成了原空间并移动到了本地内存,这是因为永久代是受虚拟机默认值大小限制,超出的话会造成OOM,放在元空间里可以直接使用本地内存,如果内存足够大是不会出现方法区OOM这种情况。

  • 为什么永久代替换成元空间?

    官方说法是为了和JRockit虚拟机进行融合,而JRockit用户不使用永久代,所以换成了元空间,并将存放区域独立于堆,并独立于虚拟机设置的内存。 其实还有一个原因是因为永久代大小受虚拟机默认值限制(64位虚拟机默认最大82m),某些场景下会大量加载类信息,这样可能出现OOM。使用元空间之后则默认最大上限没有限制,直到耗尽系统可用内存。

  • 方法区也可会被gc管理,主要是回收常量池中部在使用的常量和不再使用的类型信息;

  • 方法区里有什么?

    • 类型信息
    • 方法信息
    • 运行时常量池
    • 静态变量
    • JIT代码缓存
    • 域信息

1. 常量池

`java`文件在编译成`class`文件后有一个`Constant pool`区域,这里都是一些对方法、类、字面量等类型的符号引用。可以看作一张表,虚拟机指令执行操作的时候需要查这张表找到真正需要的目标,在加载到内存之后会将符号引用替换成真正的地址指针,**这个时候就是运行时常量池**;
// 省略...
// 这块结构就是常量池
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // 123
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
  // ...
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               123
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
 	//...
{
 	// 省略
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String 123
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      // ...
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
例如上面的字节码就是一个简单的`main`函数里面打印一个`123`,编译期间能够确定字面量123,把它放到常量池中,在运行期间,执行到这个代码之后会在`heap`中创建`123`字符串对象,然后`StringTable`对他持有引用;

常量池中有什么?

各种符号引用以及字面量,等待被加载的时候换成真正的引用

  • 字符引用
  • 数量值
  • 类引用
  • 方法引用
  • 字段引用

2. 运行时常量池

  • 运行时常量池是方法区的一部分,都放在方法区也就是元空间(java8-HotSpot)
  • 字节码文件中的常量池在类加载后会放到运行时常量池中
  • 在常量池加载到运行时常量池后它里面的符号引用会替换成真正的地址引用,字面量也会在堆中创建对应的对象。
  • **总结:**常量池像是菜谱中做一道菜描述的所需要的材料,而运行时常量池就像真正做菜的时候你自己准备的一些真正的材料。

5.1.3 虚拟机栈

创建线程所需要的空间大部分耗费在java栈上,默认大小1m,**但是这1m的空间用的不是堆里的!!**用地本地内存。

堆空间大小10m固定死,也能创建上千条线程。

虚拟机栈占用哪里的内存?

where is the java stack allocated

线程私有的,它用来记录线程在执行过程中调用的方法的顺序,方法的调用和入栈出栈一个道理都是后进先出。每调用一个方法就会创建一个栈帧,并压入栈里,当方法执行完毕或者抛出异常的时候就会将栈帧弹出。
image-20210326153114292
  • 大小

    可以通过-Xss来调整,linux`mac os`默认大小为1m,虚拟机总大小固定,每个虚拟机栈占用内存越大,能创建的线程数就越小。

  • 栈帧

    栈帧用来记录线程调用方法的上下文信息,比如一些方法局部变量表,操作数栈等。

  • 局部变量表

    栈帧的局部变量表用来记录在编译时期方法体内就可以确定的各种基本类型的数据和对对象的引用

  • 虚拟机栈帧是否会被gc回收?

    不会,这里一个栈帧就是一次方法的引用,调用完毕自己弹出,然后被回收掉。

  • 局部变量是否线程安全?

    具体情况,具体分析。如果局部变量被多线程共享也会出现并发风险。这里主要看局部变量的作用范围有没有逃离方法的范围。

5.1.4 本地方法栈

线程私有,在`java`程序执行的过程中有时候需要用到系统本地的一些函数帮助我们进行计算,所以需要一个能够记录对本地方法引用的栈帧。但是在`HotSopt`实现里,将虚拟机栈和本地方法栈合二为一了。

5.1.5 程序计数器

线程私有,这块区域用来记录当前线程下一个需要执行的指令在哪,也为了在线程切换上下文的时候能够恢复现场,底层是使用寄存器来实现的,并且这部分是虚拟机规范中明确确定不会发生OOM的区域。

创建对象的过程

TODO

5.2 StringTable

jdk7之前,放在方法区中(永久代里),之后放在了堆中

  • java7及以后,它放在堆中,对应的字符串也是在堆中。
  • 如果StringTable中的字符串没有被引用,也可能会被gc回收掉
  • 它的哈希表的长度固定,也是通过数组+链表的形式解决哈希冲突
  • StringTable过大导致 young gc 耗时很长,因为在gc时会判断当前字符串是不是被StringTable引用。
  • -XX:StringTableSize=10000参数调整StringTable大小,默认60013个。
  • 茴香豆时间,一个空的main方法启动后,在StringTable中有1769个字符串。

为什么StringTable要放在堆空间中?

在`jdk7`之前,放在永久代里面它的回收效率很低,因为只有`Full GC`才会触发对它的回收操作,但是我们程序运行期间大量使用字符串,所以需要更及时的回收,所以在`jdk7`中将`StringTable` 放到了对空间里。

5.3 直接内存

v2ex讨论

statckoverflow

直接内存分配在堆外,使用本地内存,不受`gc`控制,分配和回收都使用`Unsafe`类来操作。对直接内存使用虚引用,并且注册一个钩子函数,一旦对直接内存引用的对象被gc回收的话,会触发一个钩子函数然后调用`Unsafe`类的释放直接内存的方法,完成释放。

**为什么需要直接内存?**直接内存能够减少数据的拷贝操作。如果数据在堆内存中,如果需要例如`io`操作的话,为了避免`gc`操作,将这个数据来回移动,需要先把这个数据拷贝到堆外,然后才能进行后续操作,而直接内存则是避免了这种情况。

5.4 gc垃圾回收

5.4.1 如何判断对象可以被回收

  1. 引用计数器

    就是给每个对象添加引用计数器,每当这个对象被引用一次计数器就会加一,当计数器为零的时候代表当前对象不再被任何对象引用,就可以进行垃圾回收了,但当A、B两个对象相互引用的时候会出现问题。

  2. 可达性分析

    GC Root出发经过一条调用链,如果最终能够达到这个对象,那么说明这个对象还在被引用,不需要进行回收,如果到达不了说明当前对象不再被任何地方引用需要被回收掉。

    1. 类的成员变量的引用
    2. 类中的常量引用
    3. 虚拟机栈中的引用,如方法的局部变量
    4. 本地方法栈中的引用
  3. 四种引用类型

    1. 强引用

      最普通的引用方式,例如我们在代码里使用 Object a = new Object()这种形式。这种引用直到OOM也不会被回收。

    2. 软引用

      SoftReference,这种形式的引用在OOM之前会释放掉,如果释放掉的内存还是不够分配所需,则才会发生OOM

    3. 弱引用

      WeakReference,这种形式的引用只有一个GC的声明周期,在GC到来时,不管有没有人对他持有弱引用都会被回收掉。

    4. 虚引用

      PhantomReference,这种引用相当于没有引用,作用就是在垃圾回收之后能够得到一个通知。例如ByteBuffer中分配的直接内存就是这种形式,在失去了对直接内存的引用之后,会调用回调方法主动释放直接内存。

5.4.2 垃圾回收算法

  1. 标记清除算法

    通过可达性分析,找出需要被回收的对象,然后记录它在内存中的起始位置放在空闲空间列表里。下次需要分配的时候从这里面找。优点是效率高,缺点但是这种算法会产生大量的内存碎片。

  2. 标记整理算法

    通过可达性分析,找出需要被回收的对象,存活的对象向前移动以达到内存整理的目的。优点是不会产生内存碎片,缺点是由于内存移动导致引用地址变更需要重新指向,效率低。

  3. 复制算法

    将内存分为两片区域,一次只用一个,当发生垃圾回收的时候,把存活的对象放到另一个区域里,并将当前区域清空。优点是不会产生内存碎片,效率也比标记整理算法高一些,缺点是只能使用一半的内存。

5.4.3 分代垃圾回收

image-20210325175612726

垃圾回收过程

  1. 对象优先分配在Eden区,当该区域空间不足时触发一次minor GC
  2. Eden区以及FROM区中还在存活的对象复制到TO区域(TO放不下怎么办?--老年代来进行空间分配担保)
  3. 让这些对象分代年龄加一,清空EdenFROM区域
  4. 并让FROM区域和TO区域交换
  5. minor GC会触发 stop the world动作,暂停其他线程的运行,等垃圾回收结束后恢复运行。
  6. 当对象的分代年龄超过阈值时(15 因为Mark Word中4bit放对象分代年龄),会移动到老年代。
  7. 如果老年代空间不足,触发一次Full FC

内存分配策略

  • 优先分配到Eden

  • 大对象直接进入老年代

  • 长期存活的对象进入老年代

  • 动态年龄判定

  • 空间分配担保

    取之前历次晋升到老年代的平均大小,如果老年代的剩余空间大于这个值,那么就可以进行本次Minor GC,否则会触发Full GC

问题

  1. 为什么要对堆进行分代?

    不同的对象有不同的生命周期,所以可以根据这个特点对不同生命周期的对象使用不同的算法,来提高效率。比如把堆分成新生代和老年代,新生代使用复制算法快速筛选出需要回收的对象,对不经常变动的老年代使用整理算法以便充分利用内存。

5.5 垃圾回收器

1、垃圾回收器分类

  1. 串行
    1. 单线程
    2. 堆内存较小,适合个人电脑
  2. 吞吐量优先
    1. 多线程
    2. 堆内存较大,多核CPU
    3. 目标是单位时间内,STW的时间最短: 0.2 0.2 = 0.4
  3. 响应时间优先
    1. 多线程
    2. 堆内存较大,多核CPU
    3. 目标是单次STW的时间最短: 0.1 0.1 0.1 0.1 0.1 = 0.5
    吞吐量和响应时间是两者不可兼得的,想要提高吞吐量就必须要减少垃圾回收占用时间,就造成了单次回收时间过长。想提升响应时间就要多次进行垃圾回收减少单次垃圾回收时间。

2、垃圾收集器和分代的关系

![image-20210330214917821](http://qiniu.seefly.top/image-20210330214917821.png)

3、垃圾回收器的组合关系

  • CMS将在jdk14中被移除
  • Parallel Scavenge GCSerialOld GC组合将在jdk14中弃用
  • Serial+CMS组合及ParNew+Serial Old组合在jdk9中被弃用
  • CMS+Serial OldCMS无法完成老年代回收任务时的备选方案。

image-20210330215106593

4、Serial GC 串行垃圾回收器

单线程垃圾回收器。新生代采用复制算法、串行回收+STW机制的方式进行内存回收,Serial还提供了用于老年代的Serial Old收集器,采用标记整理算法+串行回收+STW机制回收老年代内存。他还作为CMS的备选方案用来处理老年代。

image-20210330223517452

5、 ParNew GC—并行版Serial回收器

是新生代垃圾回收器,相当于Serial的多线程版本,所以比它STW的时间更短一点,线程数量默认和cpu核心数相同。除了多线程外其他和Serial没有任何区别,也是采用复制算法+STW机制。这款垃圾回收器主要用在多核cpu下,在单核cpu中的效果并不一定比Serial好,因为有线程切换上下文成本。

  • -XX:+UseParNewGC 在新生代使用ParNew(java9不建议再使用)

image-20210331092005910

6、 Parallel—吞吐量优先

和`ParNew`一样采用了并行+STW+复制算法处理对新生代进行垃圾回收,区别是底层框架和目标不一样。`Paralle`目标是吞吐量优先,并且拥有自适应挑接策略。在`java6`时提供了`Paralle Old`来处理老年代(并行+STW+标记整理)。吞吐量优先适用于不需要太多交互,只专注于大量计算的程序。

image-20210331095342160

  • -XX:+UseParallelGC 新生代使用Parallel,老年代自动使用Parallel Old,java8中默认配置
  • -XX:+ParallelGCThreads 并行垃圾回收器线程数量,默认和cpu核心数相等,超过8个时 数量等于=3+[5*cpu] / 8
  • -XX:+MaxGCPauseMillis 单次最大的STW时间
  • -XX:+GCTimeRatio 总垃圾回收时间占比,在单位时间内垃圾回收占用程序运行总时间的大小。1/(n+1);其中n为设置的值,越大垃圾回收占用时间越少。这个和MaxGCPauseMillis冲突,任务总量一定,单次暂停时间越小,就需要多次执行,总和就会越大。
  • -XX:+UseAdaptiveSizePolicy 设置自适应调节策略。自动调整新生代大小、Eden和幸存区比例、晋升老年代对象年龄阈值等。

7、 CMS-响应时间优先

`CMS(Concurrent Mark Sweep)` **老年代**的并发标记清理垃圾收集器,利用少量的`STW`时间和标记清除算法进行垃圾回收,可以在垃圾回收的同时用户线程继续运行,实现了程序的低延迟。**在`jdk9`中被标记为过期,在jdk14中被移除**

image-20210331104438057

CMS工作步骤

  1. 初始标记阶段

    在该阶段所有用户线程会被STW,但是时间很短,在这个阶段仅仅标记GC Roots能够直接关联到的对象,因为数量很少,所以标记速度很快。

  2. 并发标记阶段

    这一步用户线程可以继续运行,利用初始标记阶段找到的对象开始向下遍历整个对象图,所以这一步耗时较长,但是可以和用户线程并发执行,这一步可能会出现浮动垃圾,同时也会产生漏标的问题

  3. 重新标记阶段

    由于并发标记阶段和用户线程并发执行,所以可能会在这个期间被标记的对象可能会产生变动。所以在这个阶段会产生短暂的STW,使用三色标记+增量更新的方式修正并发标记阶段的标记结果;

  4. 并发清理

    根据前面的标记结果,对垃圾对象使用标记清除算法进行清理,由于不需要对象移动所以这个阶段可以和用户线程并发执行。

CMS弊端

  • 标记清除算法会产生内存碎片
  • 会出现浮动垃圾,在并发标记阶段产生了新的垃圾的话只能在下一次GC时处理了。
  • 吞吐量降低,并发的线程会占用用户线程的执行时间,造成总吞吐量降低。
  • 会退化成为Serail,垃圾清理和用户线程并发执行,所以需要预留部分内存空间,在内存使用达到阈值的时候进行垃圾清理,如果预留的空间不够,会使用串行垃圾回收器,使用标记整理算法,耗时更长。

参数

  • -XX:+UseConcMarkSweepGC 指定使用CMS处理老年代,自动激活ParNew处理新生代。
  • -XX:+CMSInitiatingOccupanyFraction,老年代内存使用阈值,达到这个阈值后触发CMS
  • -XX:+UseCMSCompactAtFullCollection,执行完Full GC后对内存进行压缩整理,回导致用户线程暂停。
  • -XX:+CMSFullGCsBeforeCompaction,执行多少次Full GC后对内存压缩整理
  • -XX:+ParallelCMSThreads,CMS使用的线程数

总结

  • 单线程、小内存使用Serial GC,因为串行不会产生线程切换,且内存较小处理较快。

  • 高吞吐量使用Parallel GC

  • 低延迟使用CMS

8、 G1—响应时间优先

  Garbage First,在jdk7中正式启用,在jdk9中作为默认的垃圾收集器。用于处理新生代和老年代的垃圾回收,目标是在多核`cpu`大内存的应用程序中能够在延迟可控的情况下尽可能保证良好的吞吐量。

image-20210331155343406

G1将堆内存划分为默认2048个区域,每个分区大小相同,默认在1m~32m大小之间,并且为2的幂。逻辑上G1还是会有Eden区、survivor区、和老年代,只是他们被分散在各个分区上面。

回收过程概览

image-20210401092417811

详解G1垃圾收集器

美团G1

并发编程网G1

  1. 对象分配的时候还是优先分配在Eden区,Eden区满的时候会触发一次Young GC来清理新生代

  2. Young GC期间会触发STW暂停用户线程,具体分为一下步骤

    • GC Roots以及Rset出发遍历引用链标记存活对象
    • 更新Rset,就是根据 dirty card queue记录的引用变化更新Rset
    • 确定老年代对新生代对象的引用关系
    • 转移和回收,Eden存活的对象复制到survivor中空的分段里,survivor中的存活的对象年龄达到阈值,移动到老年代,如果没有就让年龄+1,释放垃圾对象
  3. 回收完成后如果老年代占用内存超过整个堆的45%,则会在下一次Young GC触发一次并发标记过程

  4. 并发标记阶段负责为混合回收阶段找出回收价值最大的老年代分区,整个过程可能会历经多个Young GC。具体分为

    • 初始标记,它是STW的,需要从GC Root触发遍历直接可达的那些对象,这个步骤和Young GCSTW是一起的;
    • 并发标记,如果发现分区中全是垃圾直接进行回收,同时计算分区中垃圾占比。它可以和用户线程并发执行,所以如果堆空间较大这个阶段可能会历经多次Young GC才能完成工作;并发标记带来的问题
    • 再次标记,由于并发标记和用户线程并发运行,过程中对象的引用可能会发生改变,所以这里会进行一次短暂的STW,使用快照算法以及三色标记,用来修正标记结果。
    • 清除阶段,也会触发STW,将完死亡的分区这里直接进行清理,部分死亡的分区进行排序,加入CSet中,让后续混合回收阶段慢慢清理
  5. 混合回收阶段就是执行Young GC的同时,同时清理并发标记阶段老年代的部分区域,这个过程可能分为多次进行。

  6. 如果老年代清理的速度赶不上对象增加的速度,比如说

    • 新生代晋升老年代发现空间不够
    • 老年代整理存活对象的时候发现空间不够
    • 为大型对象在老年代分配空间时发现找不到连续可用的内存

    这些都会触发Full GC,这个过程会进行串行回收,所以说会有较长时间的STW。应该避免这种情况发生

9、常见问题

什么是安全点

程序不能随便在一个时间点上进行垃圾回收,他首先需要在一个地方停止用户线程进行通过可达性分析算法进行标记,这个地方就是安全点。安全点一般在方法调用前、异常跳转以及循环跳转上。

如何在gc前让所有用户线程都在安全点上停下来

使用一个标志位,每个线程在安全点上都检查一下这个标志位,如果标志位为真的话就在这里停下来。

安全区是什么

安全区是指在一段范围内,对象的引用关系都不在发生变化了,在这片区域内`gc`线程都可以进行垃圾回收

垃圾回收吞吐量

吞吐量 = 用户线程执行时间 / 垃圾回收器执行时间;例如100分钟内 其中99分钟是用户线程在执行,那么它的吞吐量就是99%。

Minor GC、Major GC 、Full GC的区别

  • 部分收集
    • 新生代收集------Minro GC / Young GC,Eden满的时候会触发,幸存区满不会。
    • 老年代收集------Major GC / Old GC,
      • Major GC 和Full GC会混淆使用,需要确认时对整堆收集还是部分收集
  • 整堆收集
    • Full GC------收集整个堆空间和方法区

浮动垃圾

  • 浮动垃圾如何产生的?

    使用三色标记法,将对对象的标记分为三个阶段:对象还没开始标记,这时候是白色的,如果有至少一个引用没有扫描过则是灰色的,所有引用都扫秒完成之后是黑色的。由于并发标记用户线程也能修改对象的引用,如果对已经扫描过的引用做了删除,那么被引用对象仍然被认为是存活的,需要下次GC才能清理。

  • 如何解决浮动垃圾?

    只能等待下次GC

并发标记过程中的漏标

黑色对象引用了白色对象,但是这个白色对象已经被标记为不可达了,就会出现漏标。在重新标记阶段,`CMS`利用增量更新和三色标记来解决这个问题。对于修改了引用的对象都标记为灰色,在重新标记阶段重新扫秒。在`G1`中使用原始快照+三色标记,在重新标记阶段扫秒这些引用发生变动的对象。

如何记录引用的变化?

利用写屏障,对对象的引用修改前后都有写屏障来进行一个类似`aop`的操作。

image-20210401152657190

每个对象都有一个RSet用来记录其他对象对自身的引用,比如原来某个属性指向对象A,后来又指向B。这是A就丢失了一个对自身的引用,通过写前屏障来记录。而B对象增加了一个对自身的引用,通过写后屏障记录,这些都记录在RSet中。

OopMap

在GC时需枚举GC Roots进行可达性分析,GC Roots又包含虚拟机栈中引用的对象。如果对栈里面的栈帧做全扫秒的话是非常耗时的,所以为每个虚拟机栈维护一个OopMap数据结构,用来记录在栈帧的那个地方是对象引用,r然后在枚举根节点的时候只要读取这个数据结构就能快速枚举根节点了。

10、G1优化建议

  • 避免设置太小的目标暂停时间,太小的话会导致吞吐量下降
  • 不要固定年轻代大小,这样会覆盖目标暂停时间

5.5 类加载

1. 类的加载过程

image-20210326170854026
类的加载过程分为加载、验证、准备、解析和初始化,这几个步骤的相对顺序是固定的,单并不是串行执行。
  1. 加载

    通过类的全限定名从某个地方加载类的二进制字节码文件,将加载的静态数据结构转换为方法区(元空间)里的运行时数据结构,然后在内存中(堆中)生成一个代表这个类的Class对象,作为访问这个类的入口。

    • 什么时候加载?

      不确定

    • 如何加载?

      从本地系统直接加载、通过网络、从压缩文件、数据库等,没有强制约束可以自由实现。

  2. 连接

    1. 验证

      校验加载进来的数据不会对虚拟机产生危害,并且校验它是否符合规范。

      • 文件格式验证、元数据验证、字节码验证、符号引用验证
    2. 准备

      为类变量分配内存并初始化,常量直接分配内存和赋值。

    3. 解析

      将常量池中的符号引用替换成直接引用

  3. 初始化

    执行类的初始化动作,就是将类变量进行赋值,以及执行静态代码块中的代码。

    • 什么情况会导致类进行初始化?
      • 创建类的对象实例
      • 访问类成员
      • 调用类的静态方法
      • Class.forName()
      • 子类初始化导致父类初始化
      • 虚拟机的启动类

2、类加载器

  1. 启动类加载器

    它只负责加载java的核心类库,不加载其他的类,没有父加载器。它是最底层的由c/c++实现;

  2. 扩展类加载器

    java语言编写,启动类加载器是他的父加载器。它只负责加载扩展程序,例如从jre/lib/ext文件夹下加载。

  3. 引用程序类加载器

    由java语言编写,扩展类加载器是它的符类,只负责加载classpath路径下的类库,我们编写的应用程序类都是由它加载的。

3、双亲委派机制

就是类加载器收到了加载类的请求,首先会委托父类加载器去尝试加载,父类加载器也是同样的先向上委托,直到启动类加载器。如果启动类加载器加载不了才会让子类加载器去加载。这样可以保证程序运行安全,避免类被重复加载。

6、Spring

1、Spring常见问题

  • 1、Spring生命周期

    • 读取配置文件
    • 将配置文件解析成BeanDefinition
    • 应用各种BeanFactoryPostProcess的子类BeanDefinitionRegistryPostProcess,用来添加额外的定义信息
  • 再应用各种BeanFactoryPostProcess修改定义信息,比如对于属性值占位符的功能的实现就是在这一步做的

    • 然后就是实例化对象,在实例化对象前后也有钩子函数就是BeanPostProcess的子接口InstantiationAwareBeanPostPorcess
    • 实例化后,属性填充前也会调用InstantiationAwareBeanPostProcess的属性填充钩子方法,对于@Autowrie注解功能的支持就在这里做的。
    • 然后是属性填充,@Autowire、@Value、@Resource注解标注的在这里进行填充,利用postProcessProperties阶段
    • 初始化
      • 初始化前,执行BeanPostProcess的钩子方法,这一步会对各种Aware接口做支持,支持JSR的@PostConstruct注解
      • 初始化:调用生命周期的初始化方法
      • 初始化后,执行BeanPostProcess的钩子方法
    • 将对象放入单例对象池中

    参见

  • 2、BeanFactory和ApplicationContext区别

    BeanFactory相当于一个对IOC容器访问的客户端接口也可以理解成一个Ioc容器,提供了一些基本的getBean方法,相当于整个Spring框架的心脏。

    ApplicationContext也是继承自BeanFactory,但是它扩展了一些其他功能,例如广播通知、国际化、资源加载等。

    前者是懒加载的,只有在使用的时候才会根据BeanDefiniton创建实例,后者在容器启动之后就会提前对单例对象进行实例化。

  • 3、BeanFactoryFactoryBean

    BeanFactory可以理解为Ioc容器,可以通过getBean的方法从容器中获取Bean对象。

    FactoryBean可以理解成一个特殊的Bean对象,它提供一个getObject方法用于生产Bean对象

  • 4、BeanPostProcessorBeanFactoryPostProcessor

    这俩都是Spring生命周期中的钩子接口,一个是面向Bean对象的,一个是面向BeanDefinition的。

    BeanPostProcess提供初始化前、初始化后两个钩子方法用来这这两个阶段做自定义处理,比如说AOP功能的实现就是在初始化后的钩子方法里做的,同时它还有一个比较重要的子接口InstantiationAwareBeanPostProcess,这个接口有增加了三个钩子方法分别针对实例化前、实例化后、以及属性填充时。比如我们常用的各种Aware接口、@Resource、@Value、@Autowried、@PostConscract注解都在这里实现具体功能。

    BeanFactoryPostProcess提供了修改Bean定义信息的一个钩子函数,在加载定义信息后可以对它做一些修改,并且子接口BeanDefinitionRegistryPostPorcessor还提供了向容器中注入额外的Bean定义信息的功能,比如我们注解驱动开发里面常用的@Componemnt及子注解以及@Bean@Configuration注解都在这里做实现

  • 5、Spring Bean Scope

    • 单例模式,容器启动后就会提前创建一个单例
    • 原型模式,每次调用BeanFactory.getBean方法都会获取一个新的Bean对象
    • 会话模式,在web环境下,对不同的session创建新的对象
    • 请求模式,在web环境下,对不同的请求创建新的对象
  • 6、Spring的注入方式有哪些?为什么推荐构造器注入

    • @Resource
    • @Autowired
    • @Import
    • @Bean
    • xml配置文件
    • 使用BeanDefiniton

    避免依赖的对象为空,并且不可变,在使用的时候能够确保已经完全初始化了。

  • 7、@Resource和@Autowired区别

    前者是jdk中自带的注解和@PostConstruct注解一样都是属于JSR-250的规范,只是spring对他做了支持,优先按名称注入

    而@Autowried注解是Spring自带的,优先按照类型注入,然后按名称注入;

  • 8、三级缓存解决循环依赖的过程

    比如说A\B两个Bean相互依赖,此时先创建A对象,标记当前对象正在创建,创建完成之后在三级缓存中放入获取A对象的回调方法,然后去填充属性,在填充属性的时候触发B对象的实例化,和A相同的逻辑,同样在经历属性填充的时候发现依赖A对象,在单例对象池中找不到,然后发现A对象正在创建,这时候就知道出现了循环依赖,它会去二级缓存中找,找不到再从三级缓存里面找,然后调用刚才的回调方法获取半成品的A对象,引用赋给B对象,他把放到二级缓存中,然后B对象处理完成,退出来处理A对象将B对象的引用赋给A对象,A对象继续进行后续的属性填充工作以及后面的初始化流程等,最后将A对象从二级缓存中取出来放到一级缓存中。这时候A\B两个对象都是完整的Bean了。

  • 9、为什么要三级缓存?二级不行吗?

    三级缓存主要是为了解决AOP+循环依赖的这种情况,普通情况下,创建代理对象这个步骤是在Bean的生命周期的最后一段也就是初始化后这个钩子方法进行,保证被代理对象走完完整的生命周期了。如果出现了循环依赖和AOP,那么会让对象提前被代理,这样可以保证依赖对象获得的是代理后的对象。如果不使用三级缓存,那么只有两种情况,要么依赖对象获得的不是被代理后的对象(这显然不行),要么每个被代理对象都提前进行AOP保证被人获得的是被代理后的对象(这样又破坏了整个生命周期流程);

    在普通循环依赖和没有循环依赖的情况下,三级缓存是完全没有作用的,之所以这么设计是应为Spring没法保证后面是否会发生AOP+循环依赖这种情况。

    参见

  • 10、如何解决循环依赖?

    除非使用构造器注入,会报循环依赖异常,平常的@Autowried注解和@Resource注解都会被默认解决掉。这个时候可以用@Lazy注解,在执行构造函数的时候注入TargetSource代理对象,破坏直接依赖关系,这样就能解决。

  • 11、SpringAOP原理,Advice和Advisor

    以注解驱动的来说吧,使用@EnableAspectJAutoProxy注解开启Spring的代理功能,逻辑就是这个注解中使用@Import注解并配合向容器中注入一个BeanPostProcess的子类InstantiationAwareBeanPostProcess它用来在类实例化的后扫秒容器中的切面信息,缓存起来,在Bean初始化后的阶段生成代理对象放到容器里面。

    • Advice

      一般叫做通知,就是我们的增强逻辑

    • Advisor

      一般叫做增强器,包含一个通知和一个切入点,在AOP联盟的规范里没有这个东西,这是Spring自己造的。

      使用切入点匹配需要增强的地方,然后在这个地方应用通知。

  • 12、AOP的两种代理方式

    • CGLIB

      针对没有实现接口的动态代理,使用字节码技术动态生成被代理目标的子类字节码,在这里面做增强,需要注意的是由于是继承所以无法代理声明为final的方法

    • jkd动态代理

      默认使用这个,如果被代理对象实现了接口会使用反射动态的创建一个和它实现了相同接口的代理对象,并把被代理对象包含在里面,在调用前后执行代理对象的钩子方法。

  • 13、AOP作用

    记录日志,控制访问权限

  • 14、Spring事件及原理

    利用观察者模式,整个实现分为这几个角色事件发布者就是ApplicationEventPublisher、事件ApplicationEvent、事件监听器ApplicationEventListener、以及多播器ApplicationEventMuticaster;主要逻辑是这样的,在容器启动的时候会注册一个默认的多播器叫什么SimpleApplicationEventMuticaster,它里面维护了一组监听器列表,当事件发布的时候通过遍历这些监听器列表的onApplicationEvent接口来达到通知的效果。注册监听器的方式可以通过@EventListener注解,或者向容器里面注入你自己的ApplicationEventListener实现类。有些容器内置的事件我们需要特殊配置一下,因为他是在整个容器可用之前发布事件,所以可以通过在MATE-INF/spring.factories中配置自己的监听器,或者利用ApplicationContextBuilder定制化容器注册我们自己的监听器。

    另外还支持顺序消费,多个监听器对同一个事件感兴趣的话可以利用@Order注解或者实现Order接口,来达到顺序消费的目的。

    同时还支持异步消费,配合@EnableAsync注解开启异步方法的支持,然后在监听器上标注@Async注解就可以了。

    Spring docs

    参见

    参见

  • 15、Spring注解扫描如何实现的

    这个主要依赖Spring提供的扩展点来实现的,具体就是BeanFactoryPostProcessor的子类BeanDefinitionRegistryPostProcessor,在它的钩子方法里面注入额外的Bean定义信息。具体的实现类就是ConfiguationClassPostProcessor,他会利用反射扫秒类上面的@ComponentScan、@Configuration、@Bean等注解,生成BeanDefinition放到容器里。

  • 16、Spring中的设计模式

    • 工厂模式,整个Spring的核心

    • 模板方法模式,源码里面大量使用,比如整个BeanFactory的继承体系、RestTemplate、JdbcTemplate、RedisTemplate等

    • 观察者模式,Spring的事件发布监听就是

    • 代理模式,Spring的AOP代理

    • 适配器模式

  • 17、Spring的事务传播行为

    • REQUIRED,如果当前没有事务的话就新建一个事务,并执行,如果已经存在事务了就加入当前事务。

    • REQUIRED_NEW,不管有没有事务,都新建一个事务。并且不会影响之前的事务

    • NESTED,如果当前没有事务的话就新起一个事务,有事务的话就创建一个子事务,相当于在执行前创建一个保存点,如果子事务发生异常就回滚到保存点,没有发生异常就等到父事务执行完毕再一起提交;参见

    • SUPPORTS,有事务就加入进去,没有就不用事务执行

    • NOT_SUPPORTED,有事务就把当前事务挂起,没有就直接执行

    • NEVER,没事务还好,有事务就报异常

    • MANDATORY,和NEVER相反,没有事务就抛异常,有事务就加入。

  • 18、Spring的隔离级别

    • DEFAULT,默认的隔离级别,此时隔离级别取决于使用的数据库,如果是Mysql就是可重复读的隔离级别,如果是Oracle就是读已提交。
    • 读未提交,当前事务能够读取到另一个事务还没提交的数据,会出现脏读,但并发量高
    • 读已提交,当前事务只能读取到别人已经提交的数据,保证不会发生脏读,会出现不可重复读
    • 可重复读,保证在一次事务中对相同的数据多次读取的结果是一样的,会出现幻读
    • 串行化,所有事务串行执行能够解决幻读
  • Spring事务的实现原理

    差不多和AOP一样的原理,也是利用BeanPostProcessor在Bean初始化后扫秒@Transaction注解,看看方法需不需要被代理,如果需要就包装一下。

  • Spring Cache如何实现的

  • Spring 插件

2、Spring MVC常见面试题

  • 1、Spring MVC执行流程

    先说一下大体的执行逻辑,请求到达前端控制器DispathcerServlet,根据HandleMapper找到执行器并调用,返回ModelAndView,然后利用视图解析器得到视图对象,再使用数据进行渲染并最终返回渲染结果给前端。

    请求到达前端控制器,这里根据请求路径查找对应的处理器,处理器包含了最终需要执行的方法和一系列前后后置拦截器;由于有各种不同的处理器,例如我们最常用的@Controller注解的这种方式,还有Controller接口做实现的,所以需要对他们做适配。之后就执行前置拦截器,根据返回结果决定是否继续执行,然后就是利用各种HttpMessageConveter做参数转换了,根据请求接口需要的参数从请求中拿出来然后做转换,最后调用处理器。然后再执行后置的拦截器,最后根据返回结果使用视图解析器解析,得到对应的视图,把视图和数据进行渲染最终返回。

  • 2、@RestController和@Controller区别

    前者表示当前Controller的返回值会直接响应给前端,不用找对应的视图去渲染,它是@Controller+@ResponseBody的组合注解。后者如果没有配合@ResponseBody的话会根据返回值查找视图渲染的。现在前后端分离的情况下都是使用前者。

  • 3、如何获取URL中{}里变量的

    使用@PathVariable注解,这和和参数解析器有关系,就是HandlerMethodArgumentResolver做的工作。在处理器适配器真正调用处理器之前肯定需要根据处理器需要的参数从请求种获取,这部分的工作就是这个接口干的,容器里面有各种各样的参数处理器。循环遍历调用他们的support方法看看支不支持对当前参数的处理,如果支持就调用他们的处理方法进行解析工作。

  • 4、Spring MVC如何重定向和转发的

    重定向使用字符串返回值,以redirct:开头后面跟重定向的路径。转发使用forward:开头后面跟路径。

    对于重定向是返回302状态码,告诉客户端需要重新请求另一个地址。而转发则是系统内部的行为。

    使用RedirectAttributes能够解决重定向带参数的问题。

  • 5、Spring MVC常用注解

    @Controller@RequestMapping@RestController@ResponseBody@RequestBody@PathVaribel@RequestParam

    @GetMapping@PostMapping@PutMapping@DeleteMapping

  • 6、如何解决Post请求中文乱码,Get乱码

    乱码就是编码和解码用的不是同一种编码方式,根据具体情况判断需要使用那种方式编码、解码就能解决实际问题。

  • 7、Interceptor和Filter区别

    过滤器是面向Servlet的,而拦截器是面向我们的写方法的。而且过滤器执行的时机一般都更靠前一些,一般用来做权限验证,字符集编码等解决方式。但是过滤器的使用方式更多,更灵活。例如springAOP里面的过滤器有前置、后置、环绕等拦截方式。

  • 8、Spring MVC的异常处理

    现在配合SpringBoot来做全局异常处理非常简单,利用@ControllerAdvice声明一个全局异常捕获类,配合@ExceptionHandler来处理特定的异常,非常好用。

  • 9、SpringMVC的控制器是不是单例的?是的话有什么问题?怎么解决?

    不是单例的,会存在并发风险,最简单的做法是不要在单例对象中写共享变量,这样就能避免并发风险。

  • 10、Spring MVC的RequestMapping方法是线程安全的吗?为什么?

    不是线程安全的,它是单例对象被多个线程共享,会有线程安全问题。因为在初始化解析的时候就是扫秒带有@RequestMapping注解的方法,利用反射得到Method对象包装成为一个MethodInvoke,利用反射调用。

  • 11、介绍下WebApplicationContext

    image-20210408224819455

    在SpringMVC里面是有父子容器的,一些Web相关的Bean例如Controller在子容器里面,而其他类似于Service的基础Bean在父容器里面,子容器可以访问父容器,反过来则不行,所以Controller可以注入Service,反过来也是不行的。如果和SpringBoot集成之后就没有这种问题了。

  • 12、如何解决跨域?

    如果是SpringBoot就配置一个CorsFilter注入到容器里面,然后配置允许的方法,以及路径之类的。也可以在Nginx这一层做解决,一般都是在网关哪里就弄好了。

  • 13、Validation了解吗?

    做参数校验用的,一般在接口哪里对封装的参数做完整性校验用的,就是用 @Validate注解标这个参数,然后再包装类里面写各种校验逻辑,比如@NotNull @NotEmpty @Max @Min等。高级一点的还有分组校验

  • 14、Json处理如何实现的

    反射调用目标Controller中的目标方法前,先获取调用该方法所需要的参数,将这些参数取出来遍历,利用各种方法参数处理器来做处理,对于Post方法请求的请求体中Json数据,会使用RequestResponseBodyMethodProcessor来处理,其实它底层是依赖各种HttpMessageConverter来实现的,比如Spring默认自带的json数据处理方式就是用Jackson的消息转换器做的。

    对于出站数据,和入站数据差不多的套路,也是利用HttpMessageConverter

3、SpringBoot常见面试题

  • 1、SpringBoot核心注解

    @Bean@Componment\@Configuration@Service@EnableAutoConfig@ComponemntScan

  • 2、SpringBoot自动配置原理

    @SpringBootApplication注解集成--->@EnableAutoConfiguration--->@Import--->ImportSelector接口

    ---> 扫秒META-INF/spring.facotries文件中的配置,执行这些自动配置类,他们借助@Condition注解决定是否向容器中添加各种Bean的信息.

    总结一下: 约定在META-INF/spring.factories下读取各种自动配置类,借助@Import注解启动时导入这些配置信息,在调用BeanFactoryPostProcessor那里借助ConfigruationClassPostProcessor来处理@Import注解,并导入信息.利用@Condition注解决定哪些自动配置类应该生效以及应该生成哪些Bean放入容器中.

  • 3、@Enable类型注解如何实现

    各种@Enablexxx其实都是借助@Improt注解来实现的,这个注解里面可以利用ImportSelector或者ImportBeanDefinitionRegistrar来自定义导入各种BeanDefiniion信息。底层依赖BeanFactoryPostProcessor

  • 4、Spring Boot 如何实现内嵌的Servlet容器的

    使用嵌入式的Tomcat,在容器刷新阶段创建Tomcat实例,配置上下文等

  • 5、@Conditional原理

    在ConfiguationClassPostProcessor解析配置文件类的时候先根据@Condition注解及其子注解来判断是否应该跳对当前配置类,或者是Bean配置方法。

  • 6、异步@Async原理

    估计和AOP差不多,在初始化后的钩子方法里面判断当前方法是否被@Async标注,是的话生成代理对象,执行的时候使用线程池来调用。

  • 7、什么是YAML

    专门用来编写配置文件的语言,可以写注释,层级分明,简介。

  • 8、bootstrap.properties和application.properties

    前者优先级更高一些,使用时机更早,一般把配置中心的一些配置放在里面用来在启动的时候去拉取配置信息。

  • SpringBoot事件和Spring事件的关系

  • Spring Boot Actuator

MyBatis

  • # 和 $ 区别

    #{}这种方式是占位符处理,会讲传入的参数进行替换,有效避免sql注入。${}这种是直接进行sql拼接,你传入什么就拼接什么,应该避免使用

  • 一级二级缓存

    一级缓存是面向单个SqlSession的,一次会话里执行多次相同的查询会用得到。二级缓存是面向多个SqlSession的。

MySql

美团InnoDB

  1. mysql调优
  2. mysql常见面试sql语句
  3. mysql索引
  4. mysql锁

微服务

一、Eureka

  • 自我保护机制

    默认情况下,实例会以30秒每次的频率向注册中心发送心跳续约,注册中心超过90秒没有收到某个实例的心跳的话,默认会将这个实例信息剔除掉。但是,如果注册中自己的网络发生的问题,也就是说出现了网络分区,这个时候可能大部分的实例心跳它都是接收不到的,这个时候如果直接把这些超时的实例信息剔除掉的话肯定是不合理的,所以eureka有一个自我保护机制用来避免这种情况。具体是这样的,它有一个期望的续约阈值就是当前实例数量*2,表示正常情况下一分钟里面应该能接收到这个数量的心跳,还有一个最低的心跳阈值,就是期望阈值的百分之八十五,如果某一分钟内收到的心跳数量小于这个阈值的话就认为可能出现了网络分区,这个时候就不再对超时没有续约的实例进行剔除了,这样能够保证它的高可用。

  • 缓存机制

    注册中心有两级缓存分别是读写缓存和只读缓存,另外维护一个全量的注册表。在服务注册的时候会在注册表中注册信息,服务断拉取注册信息的时候会先从只读缓存中获取,如果获取不到再从读写缓存中获取,最后从注册表里获取更新到读写缓存里。只读缓存和读写缓存通过30秒每次的定时任务进行同步,读写缓存有个180秒的默认过期时间,同时在服务剔除(主动下线、超时未续约)的时候也会更新读写缓存。

    如果服务异常下线,其他客户端可能会经过很长时间才能直到。90秒没心跳--->最长经过两个剔除周期发现超时-->剔除-->更新注册表、读写缓存--->30秒每次的同步更新读写缓存--->客户端30秒每次拉取注册信息

    120+30+30=180,最长3分钟,最短90秒

  • ServiceInstance 和 DiscoryClient

    ServiceInstance是实例信息,描述了实例的地址,端口以及续约信息之类的,可以通过DiscoryClient获取。

    DiscoryClient相当于一个服务发现客户端,用户根据服务名称获取对应实例列表信息的。

二、Ribbon

gisq.microSharedmain.tax.ahdkTax.expandName = 1340DZBDCJY

gisq.microSharedmain.tax.ahdkTax.expandPwd= 1340DZBDCJY

三、Feign

四、Hystrix

[Hystrix Vs Sentinel](https://sentinelguard.io/zh-cn/blog/sentinel-vs-hystrix.html)

处理分布式系统中的**延迟**和**容错**的开源库,解决分布式系统调用失败时不会导致级联故障,从而导致整个服务失败

断路器相当于一个开关,由于多次调用失败回导致开关打开,不再进行调用而是直接返回一个备选方案,避免长时间阻塞在失败的调用用上,影响整个系统的可用性。

image-20210415161737690

Hystrix wiki

核心要点分析

  • 1、what dose hystrix do?

    • 防止单个依赖耗尽所有的用户线程
    • 减少负载并快速失败,而不是排队等候
    • 始时检测
    • 动态控制断路器阈值
  • 2、how dose hystrix do?

    • 对外部依赖使用单独线程执行,并且维护一个单独线程池,如果线程池打满直接拒绝请求,而不是排队等候。
    • 使用自定义超时阈值控制,进而进行快速失败
    • 如果某个依赖的错误百分比超过阈值,直接打开断路器,在一段时间内不再请求
    • 如果请求失败,执行备选方案
  • 3、执行原理

    image-20210416152808829

    用户请求过来,首先判断当前断路器是否允许请求具体就是看看断路器有没有打开,如果是关闭状态再判断线程池能不能再接收新的任务,可以的话就执行,如果执行超时或者异常就进入fallbakc,如果断路器打开或者线程池不能接受新的任务也会fallbakc,如果断路器处于开状态会尝试一次调用,成功的话将断路器关闭,失败的话继续打开。

    断路器内部维护一个时间窗口,默认是10秒,每秒配置一个桶位,每增加一秒就添加一个新桶位,移除一个最老的桶位,每个桶位上面记录调用成功、超时、异常、线程池拒绝次数,通过配置的请求次数阈值和失败率以及时间窗口大小决定断路器是否打开。

  • 4、服务雪崩是什么?

    微服务架构里面, 服务和服务之间都是相互协作相互依赖的,如果某个服务突然因为一些原因导致不可用了或者是达到了瓶颈,那么依赖他的这些微服务可能都会出现问题,最终导致整个系统都不可用。

  • 5、熔断、降级、限流都是什么?

    如果某个服务由于某种原因比如超时、服务内部异常等等,这时候调用者会使用一个备选方案代替这次错误的调用,这个过程就叫降级。如果对某个服务在一段时间内多次都无法访问的话,此时就可以认为这个服务可能出现了严重的问题,后面我就不在尝试了,直接使用降级的备选方案,经过一段时间之后再尝试调用几次如果发现成功了,就关闭熔断状态。限流比较好理解,就是服务的承载能力有限,如果请求太多可能会把这个服务搞崩掉,这时候就需要对流量进行限制,超出服务能力的流量给他进行降级,这样能够保证服务可用,不至于被搞崩掉。

    限流 导致 服务降级

    熔断 导致 服务降级

  • 6、常见限流算法以及实现

    • 计数器、固定窗口

      假设一个窗口大小为10秒,每个窗口能处理的请求量是100个, 每过来一个请求,计数器就加一,经过一个时间窗口之后计数器清零重新开始。这能够保证在一个时间窗口内的请求量不会超过100个,但是会面临这样一个问题,第一个窗口的最后一秒钟来了100个请求,第二个窗口的第一秒钟也来了100个请求。这两个窗口都没有超限,但是在服务器看来,它是两秒钟接收了200个请求,显然是有缺陷的。
      
    • 滑动窗口

      tcp滑动窗口动画演示

      针对固定窗口算法,滑动窗口将一个窗口拆分成n个小窗口,每次向前移动一个小窗口的距离,这样能够进行更细粒度的控制,但是小窗口的大小不能无限的细化,一般是以秒为单位。能报保证精确到秒的统计下,窗口落在时间线上的任何一段请求数量都在指定的范围内。但是缺点是不能应对突发流量,比如说窗口的第一秒钟突然来了100个请求。
      
    • 漏桶算法

      高并发系统设计

      维护一个固定大小的桶,用户的每次请求都当作水滴一样流入桶中,我们不控制水流入的速度,只按照固定速率让水从桶中流出,这个过程可以看作是对用户请求的处理。如果水流入的速度大于流出的速度,那么经过一段时间,桶就会满,这个时候可以执行拒绝或者其他的策略。这种算法**没法应对突发大量请求**,突然间大量的请求过来服务线程还是按照固定的速度处理。
      
    • 令牌桶算法

      和漏桶算法类似,但是过程是相反的。维护一个固定大小的令牌桶,以固定频率向桶中放入令牌,满了则抛弃。请求过来的时候先从桶中获取令牌,如果拿到了就去执行,拿不到的话就决绝执行。这样能够面对突发流量,以及实现按照固定频率执行任务。
      

滑动窗口限流、框架限流思路、令牌桶

Future怎么实现的

cas锁饥饿怎么解决

Jvmhook有没有听过

中间件

一、Redis

二、RocketMq\Kafka\RabbitMq

计算机网络

  1. tcp三次握手四次挥手
  2. udp
  3. 七层/五层网络模型

数据结构和算法

  1. 链表

  2. 二叉树

  3. 几种排序算法

  4. Leecode面试常见算法

常用设计模式

  1. 单例模式
  2. 工厂模式
  3. 观察者模式
  4. 门面模式
  5. 装饰器模式
  6. 适配器模式
  7. 组合模式
  8. 代理模式

项目

一、项目中遇到最难的问题,如何解决的

git

一、常用命令

二、解决代码冲突

linux

一、常用命令

二、如何排查服务器cpu占用

DevOps


评论