20384 2020-05-24 2024-05-10

前言:考虑到本次为书面表达,会有适当的代码扩展作为选读。

希望对你能有所帮助,不定时更新(最后更新时间:2024-04-25)。

todo项:思维导图。

一、Java基础

1、接口

面试官:可以说下你对于Java中接口、抽象类的认识吗?

我:可以。

接口是Java中对于一类具有相同特征对象的统称,它可以只是一个标记,例如Spring中Aware接口,也可以具有某些代表特定行为的方法、属性,例如Spring中的BeanFactory。

抽象类是在接口的基础上扩展而来的,它提取子类中一些公共的特性,又允许子类可以有不同的实现。这里举个例子:龙生九子,九有不同。有点拗口的话,那么再换种说法:Pen抽象类中有抽象方法setColor,当子类RedPen、GreenPen、BulePen各自实现自己的相应抽象方法,就可以画出不同的颜色。

关于接口再多说一句,在Java8中,为Java添加了default关键字,并允许接口中声明静态方法,虽然这些静态方法只能通过接口类.静态方法的形式调用。

2、重载、覆盖

面试官:可以说下你对于Java中重载、覆盖的认识吗?

我:可以。

首先从定义上面区分,在单个类中,当有多个方法名相同,而入参的个数类型顺序有所不同时,这就叫做重载

覆盖又叫重写,是子类重写父类/父接口的特定方法以实现自己的专有行为。

从虚拟机层面上讲的话,重载指的是同一个类中多个同名方法的方法签名不同,方法签名只跟入参的个数、类型、顺序有关,跟返回值无关;重写指的是子类覆盖父类中相同方法签名的方法。

3、代码验证

以下为本小节相关代码验证

public class InterfaceTest {

    interface Inter {
        void print();
        static void print1() {
            // 静态方法用于测试子类继承的是哪一个父接口的
            System.out.println("I am Inter print1");
        }
    }

    interface Inter1 {
        static void print1() {
            // 静态方法用于测试子类继承的是哪一个父接口的
            System.out.println("I am Inter1 print1");
        }
    }

    static class InterImp implements Inter, Inter1 {
        @Override
        public void print() {
            System.out.println("I am InterImp print");
        }
        void print2() {
            // 3个print2方法互为重载关系
            System.out.println("I am InterImp print2");
        }

//        String print2() {
          // 这个方法不能够被声明,因为同一个类中不能存在方法签名相同的两个方法
          // 与上面方法相比,仅类型不同,而方法签名与上面相同
//            return null;
//        }

        String print2(int i, String s) {
            return null;
        }

        String print2(String s, int i) {
            return null;
        }
    }

    public static void main(String[] args) {
        InterImp interImp = new InterImp();
        // 可以调用print、print2方法,但不能调用print1
        // 所以接口中的静态方法不能被继承/实现
        interImp.print();
        interImp.print2();
        
        // 这里再扩展一下,父类的静态方法可以被重写吗?或者说可以被覆盖吗?
        // 答:静态方法没有重写这个说法,覆盖是可以的,因为静态方法绑定于类,跟对象实例无关,例如下面这个例子
        Father father = new Son();
        // I am father
        father.print();
        Son son = (Son) father;
        // I am son
        son.print();
    }
}

class Father {
    static void print() {
        System.out.println("I am father");
    }
}

class Son extends Father {
    static void print() {
        System.out.println("I am son");
    }
}

再附上字节码分析,如下

javap -s InterfaceTest\$InterImp.class > temp1.txt

Compiled from "InterfaceTest.java"
class site.xiaokui.common.hk.mianshi.InterfaceTest$InterImp implements site.xiaokui.common.hk.mianshi.InterfaceTest$Inter,site.xiaokui.common.hk.mianshi.InterfaceTest$Inter1 {
  site.xiaokui.common.hk.mianshi.InterfaceTest$InterImp();
    descriptor: ()V

  public void print();
    descriptor: ()V

  void print2();
    descriptor: ()V
  
  # 这是被注释掉的方法,因为方法签名冲突了
  # 可以看到,与上面的方法对比只有返回值不同,为什么方法前面不包含返回值/异常呢?
  # 这个问题有点超出本文的讨论范围了,这里给个链接,个人觉得这两个回答都是高质量的
  # https://segmentfault.com/q/1010000004659660
  # java.lang.String print2();
  #   descriptor: ()Ljava/lang/String;

  void noSignatureMethod();
    descriptor: ()V

  java.lang.String print2(int, java.lang.String);
    descriptor: (ILjava/lang/String;)Ljava/lang/String;

  java.lang.String print2(java.lang.String, int);
    descriptor: (Ljava/lang/String;I)Ljava/lang/String;
}

二、Java集合

1、HashMap

面试官:可以说下你对于Java中HashMap、ConcurrentHashMap、Hashtable、HashSet、LinkedHashMap、TreeMap的理解吗?

我:可以。

当然实际中问题应该是以连环炮的形式展开的,这里只是为了行文方便。

先说下HashMap,所有哈希类的容器主要关注以下三个点:

  • 如何计算键的哈希值,这个哈希值的确定不是一件易事
  • 值如何定位到哈希表/桶/数组位置,兼顾容器大小的同时,还要尽量保持均匀,减少哈希冲突,相关数据结构的课程有专门的课题研究
  • 以及如何解决哈希冲突,可以使用链表延长节点、树化节点、扩容

特别的,这里对红黑树做一下简单的要点说明:

  • 平衡二叉树、子树差大于1、根是黑色、叶子节点是黑色、其他节点为红色或黑色
  • 红黑树是一种平衡二叉查找树,它通过红黑染色和旋转操作保持二叉查找树基本平衡,从而保证最坏情况下的时间复杂度为 O(log n)。这样做的好处是,在HashMap的大部分操作(添加、删除、查找)都保持着较低的时间复杂度。

hashCode扩展

// 这里简单延伸一下对hashcode的讨论
// 来自类 String
public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

// 来自类 Integer,这个是比较简单的
public int hashCode() {
	return Integer.hashCode(value);
}
public static int hashCode(int value) {
    return value;
}

/**
 * As much as is reasonably practical, the hashCode method defined by
 * class {@code Object} does return distinct integers for distinct
 * objects. (This is typically implemented by converting the internal
 * address of the object into an integer, but this implementation
 * technique is not required by the
 * Java&trade; programming language.)
 */
// 来自类 Object,这里简单对注释翻译一下,如下
// 对于同一个对象,在应用中的多次调用,应该返回同一个哈希值
// 如果对象修改了,那么哈希值也应该修改,并且通过equals方法可以比较
// 在可行的范围内,hashcode的生成是通常是根据对象的物理地址生成的,虽然在Java语言中并不需要这种实现
public native int hashCode();

// 大部分场景下,我们可以根据自己的需要 同时 重写hashcode、equals方法
// 而有些时候当我们认为两个类的所有属性都相等时,两个对象就相等
// 这时可以使用org.apache.commons.lang3提供的工具类,也可以使用lombok
@Override
public int hashCode() {
    return HashCodeBuilder.reflectionHashCode(this, false);
}
@Override
public boolean equals(Object object) {
    return EqualsBuilder.reflectionEquals(this, object, false);
}
@Override
public String toString() {
    return ToStringBuilder.reflectionToString(this,
            ToStringStyle.MULTI_LINE_STYLE);
}

2、ConcurrentHashMap

相比较HashMap,ConcurrentHashMap和Hashtable是线程安全的,而HashTable的线程安全主要来自于每个方法的synchronized关键字,大部分情况下ConcurrentHashMap与Hashtable是可以互相替换的。

这里主要讨论ConcurrentHashMap的实现,主要是以下几个点

  • JDK7中分段读写锁Segment来实现并发,当写数据出现线程竞争时只锁一部分(1.7HashMap多线程操作,触发扩容有几率发生死循环)
  • JDK8中采用transient volatile Node<K,V>[] table保持节点的可见性,把锁的位置细化的哈希数组中的单个Node节点
  • JDK8中关于锁的细化又可以分为cas和synchronized,这两者是共存的,区别在于使用场景上。synchronized用于锁住哈希数组中的单个Node节点(这个节点可能是一个节点,也可能是一个链表,还可能是一颗红黑树),而cas用于在哈希数组中的特定位置插入/更新节点

面试官:ConcurrentHashMap中为什么不允许key、value为null?为什么HashMap中允许key、value为null?

我:我的内心是翻滚的>_>,这个问题如果能抛出来的话,我对面试官的印象会提高一个档次。

这个问题首先可以追溯到源码的设计上,大概列下:

  • HashMap中对于键为null的hashcode固定为0,值可以为任意对象
  • HashMap中判断一个键值对是否存在,不能够通过value为null来判断,应该通过contains方法,且HashMap应用于单线程中,所以当contains方法返回true时,那么接下来的get一定可以取到之前放置对象
  • ConcurrentHashMap的key、value是全都不允许为null的,先来看一下value为什么不能为null。首先ConcurrentHashMap的应用场景是多线程下,假设当contains方法返回true时,接下来的get并不一定可以取到之前放置的对象,那么此时如果返回的是一个null,那么这个null就会引起歧义:这个null是之前放置的对象吗?还是这个键值对已经就为空了?所以value不能为null,所以此时返回null就说明该键值对已被其他线程修改
  • 既然value不能为null,那么为了保持统一key也设置为不允许为null呗。注意,这里的key其实是可以为nulll的,就像HashMap中把hashcode设为0即可,个人觉得这里更多的考虑是为了保持统一,因为你非要放一个null键也不是不行

以上为个人观点,欢迎指正,谢谢!

3、其他Map

面试官:你还使用过其他的Map吗?能大概说一下吗?

我:可以。

首先是HashSet,HashSet源码很简单,其实就是一个HashMap,只不过统一把键值对中的值置为了同一个空对象。

其次是LinkedHashMap,LinkedHashMap使用了双向链表--伪LinkedList以记忆键值对中值的放入顺序。

最后是TreeMap,TreeMap用得比较少,我也可以大概说下,它是把键放入了一颗平衡二叉树/二叉搜索树,因此TreeMap的遍历按照键的hashcode大小顺序遍历的。

这里简单提一下,HashMap是遍历哈希数组,因此是随机的,没有特定规则;而LinkedHashMap的遍历默认是按照键值对的放入顺序遍历的。

Map相关代码验证

public class MapTest {

    public static void main(String[] args) {
        HashMap<String, String> hashMap = new HashMap<>(4);
        hashMap.put(null, null);
        System.out.println(hashMap.get(null));
        hashMap.put(null, "11");
        System.out.println(hashMap.get(null));
        System.out.println(hashMap.get("11") + "\nHashMap测试结束\n");

        ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>(4);
        // 下面这一行不允许,会抛空指针
        /// concurrentHashMap.put(null, null);
        // 返回null
        System.out.println(concurrentHashMap.get("11") + "\nConcurrentHashMap测试结束\n");


        Map<String, String> map = new HashMap<>(16);
        putData(map);
        map.forEach((k, v) -> System.out.println(k + "=" + v + ",键hashcode=" + k.hashCode()));

        // 不能直接将map放入,否则就丢失测试的意义,默认放入就是按照之前的遍历顺序
        // LinkedHashMap默认遍历是按照插入顺序,也可指定为按照访问顺序遍历让内部 accessOrder 为true
        map = new LinkedHashMap<>();
        putData(map);
        map.forEach((k, v) -> System.out.println(k + "=" + v + ",键hashcode=" + k.hashCode()));

        // 按照键hashcode升序遍历,这里将map放入构造没关系
        map = new TreeMap<>(map);
        map.forEach((k, v) -> System.out.println(k + "=" + v + ",键hashcode=" + k.hashCode()));
    }

    private static void putData(Map<String, String> map) {
        // 造这个数据也需要花点心的
        map.put("az", "33");
        map.put("by", "11");
        map.put("cr", "22");
        map.put("cm", "00");
    }
}
// 为节省版面,省略输出....

3、List&Queue

面试官:能简单说下你所用过的List、队列吗?

我:可以。

说起List接口,肯定离不开ArrayList、LinkedList,它们之前的区别在于ArrayList内部是基于数组的,因此对于内部数组的插入、删除是比较耗时的,而对于增加、查找是比较友好的,可以使用数组下标快速定位。特别的,容器扩容会发生内部数组的复制,因此一开始最好指定大小。

而LinkedList是基于链表的,因此对于节点的插入、删除、增加是很快的,但对于节点的查找是比较慢的,因为每次查找都要从头结点开始遍历。

面试官:还能再扩展下吗?比如再深入说下LinkedList?

我:可以。

LinkedList不仅实现了List即可,它也实现了Queue(音同kju)、Deque(音同deck)接口,因此LinkedList具有列表的功能,同时还有队列和双向队列的功能。特别的,一个双向队列就可以完全模拟一个栈或一个队列的行为,因此LinkedList也可以当做栈或队列来使用。

关于ArrayList更为详细的讨论可以参见这里,关于LinkedList更为详细的讨论可以参见这里

三、Java多线程

1、线程状态

面试官:可以说下Java中线程具有的几种状态吗?

我:线程一共有7种状态:

  • 新建状态,还未被系统调用
  • 就绪状态,等待系统执行时间片,特别的,又将就绪状态和执行状态统称为运行状态
  • 执行状态,正在使用系统分配的时间片,即代码运行中
  • 阻塞状态,阻塞于外部输入输出、阻塞于锁
  • 等待状态,首先等待获取Monitor,直到被此Monitor上的其他线程所唤醒
  • 超时等待状态,在Monitor上等待特定时间,到时后自动返回
  • 终止状态,线程执行完毕,进入死亡状态

可以使用以下命令查看线程的实时状态,下面是一个例子

jps -l
4880 com.intellij.idea.Main
5953 sun.tools.jps.Jps
5318 org.jetbrains.idea.maven.server.RemoteMavenServer36

jstack 4880
# 为节省版面,省略输出....

2、synchronizd

面试官:可以说下synchronized、volatile关键字以及cas的底层实现吗?

我:可以。

1、synchronized(互斥同步/阻塞同步)

首先synchronized是重量级锁,同时也是个悲观锁,为什么怎么说呢?

Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块,状态转换消耗时间可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级(Heavyweight)的操作(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)。

  • synchronized在JVM中基于进入和退出monitor监视器对象来实现方法同步和代码块同步,指令为monitorentermonitorexit实现的
  • synchronized用的锁是存在Java对象头里的MarkWord里,存储了对象的Hashcode,锁标记位等

2、cas(非阻塞同步)

互斥同步最主要的问题是进行线程阻塞和唤醒所带来的性能问题。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗点说就是先操作,发生了数据争用再采取补偿措施(最常见的补偿措施就是不断地重试,直到成功为止)。

CAS是Compare And Swap的简称,是CPU的一个原子操作,CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。

ABA问题可以加版本解决,juc包提供原子版本类AtomicStampedReference支持,不过比较鸡肋,大部分情况下ABA不会影响程序并发的正确性。

3、volatile

  • 并发三要素 :原子性、可见性、有序性,而 Volatile 涉及了可见性与有序性,非常契合boolean这种数据类型

  • volatile会让线程的工作内存缓存失效,去读主内存里面的值,从而保证多线程情况下看到的都是最新值。

  • 还有一个语义:禁止指令重排序,通过插入内存屏障来

  • 这里还可以提下 happens-before 原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据

面试官:可以说下关于synchronized锁的几种状态吗?

我:可以。

膨胀方向:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,这几个状态会随着竞争状况逐渐升级。

1、偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由一个线程多次获得,为了让同一线程获得同一锁的代价更低引入了偏向锁,当一个线程获得锁时,会在对象头和栈帧中锁记录里储存偏向的线程ID,以后就可以直接测试是否储存着指向当前线程的偏向锁。如果有其他线程竞争锁,才释放锁,将对象头设置为无锁状态。

偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做。

2、轻量级锁

轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量

加锁:JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的MarkWord复制到锁记录中,然后线程会尝试使用CAS将对象头中的MarkWord替换为指向锁的指针。成功,当前线程获取锁,如果失败,就尝试自旋来获取锁。如果该线程一直自旋获取锁失败就会膨胀成重量级锁,线程阻塞。

解锁:会使用原子的CAS操作将栈帧中的以前复制MarkWord的替换回对象头,成功表示无竞争(是轻量级锁,另外一个线程还在自旋)直接释放,失败(因为这个时候锁已经膨胀成重量级锁),那么释放之后唤醒被阻塞的线程。

3、自旋锁

自旋锁:如果持有锁的线程能很短时间内释放锁,那么竞争锁的线程就不需要进入阻塞挂起状态,而是等一会(自旋),这样能避免用户线程和内核线程的切换消耗,但是如果超过一定时间仍未得到,还是会进入阻塞

自适应自旋锁:自选的次数不再固定,由前一次在同一个锁上的自旋时间及锁拥有者的状态决定

3、线程池

面试官:工作有使用过线程池吗?能大概说下线程池的实现原理吗?核心线程大小该怎么配置?

我:可以。

线程池主要解决以下两个问题:

  • 线程是操作系统中的珍贵资源(映射到操作系统的原生线程),线程的创建和销毁都比较耗时(响应请求8ms,创建和销毁线程各1ms),提前创建可以快速的响应请求
  • 线程资源统一存放,便于管理、监控

核心线程池大小设置:

  • CPU密集型: cpu核数 + 1或-1,减少线程上下文切换
  • IO密集型:cpu核数 * 2,尽可能让阻塞IO的线程少,线程多一点没关系

面试官:能够再深入点吗?比如一个任务提交到线程池中执行,大概会经历哪几步?

我:可以。

一个任务提交到线程池中执行,大概会经历以下几步:

  • 如果当前线程池中线程数小于核心线程数,则创建新的线程执行任务,否则下一步
  • 如果任务等待队列未满,则等待队列接纳任务,否则下一步
  • 如果当前线程池中线程数小于最大线程池数,则创建新的线程执行任务,否则下一步
  • 线程池工作已饱和,执行饱和策略,以决定任务的下一步动作,根据饱和策略的不同,任务可能有以下几种动作:丢弃抛异常让调用者线程执行任务、替换最近的任务或其他自定义策略动作

面试官:关于之前提到的任务等待队列,能简单说下有哪几种吗?可以说下它们之间的优缺点吗?

我:可以。

这里提前说下,这里的任务等待队列其实都是阻塞队列,都实现了BlockingQueue接口,且都是线程安全的

  • 第一个是ArrayBlockingQueue,内部维护了一个数组,先进先出,即第一个是待得最久的,最后一个是待得最短的,内部使用一个重入锁ReentrantLock来保证线程安全,初始化容量后不可扩容

  • 第二个是LinkedBlockingQueue,内部维护了一个链表,先进先出,内部使用ReentrantLock来保证线程安全,默认容量为无限大,如果使用应该尽量指定大小,以防止潜在的事故发生

  • 第三个是SynchronousQueue,无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素,内部直接使用CAS保持线程安全

  • 第四个是PriorityBlockingQueue,一个具有优先级的无限阻塞队列,内部使用ReentrantLock保持线程安全

至于优缺点的话,怎么说呢,它们的优点就是它们的缺点,存在即合理吧,根据不同业务场景选择即可。

关于线程池更为详细的讨论可以参见这里

线程池扩展

public class ThreadPoolTest {

    static int i = 0;

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "打印了这条信息");
            }
        };
        // 默认饱和策略就是抛错,抛RejectedExecutionException
        // 当非核心线程闲置特定时间后会被系统回收
        // 可以通过executor.allowCoreThreadTimeOut(true)来设置核心线程闲置回收
        ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 20, 1, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(10), new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "线程组A" + i++);
            }
        });
        // 执行任务
        executor.execute(runnable);
        // 活动的线程数,注意这里存在的线程并发,这里可能为0,也可能为1
        System.out.println("线程池A中活动线程数:" + executor.getActiveCount() + ",存活线程数:" + executor.getPoolSize());
        // 关闭的原理都是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程
        // 所以无法响应中断的任务可能永远无法终止
        // 等待任务执行完毕才真正关闭线程池
        executor.shutdown();

        // 最好强制指定LinkedBlockingDeque的容量
        executor = new ThreadPoolExecutor(2, 6, 4, TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(1), r -> new Thread(r,"线程组B" + i++), new ThreadPoolExecutor.DiscardPolicy());
        // 提前预热/创建所有核心线程,返回预热成功数
        int startCount = executor.prestartAllCoreThreads();
        executor.setCorePoolSize(4);
        // 注意这一步的中 executor.getActiveCount()存在的线程并发,这里的 getPoolSize值的是线程中存活的线程数,这里为2
        // 默认线程池并不会初始化线程,预热其实是一个新建线程空任务的过程
        // 所以当获取活动线程数时,可能空任务还没执行完
        // 所以如果加上这一句 Thread.sleep(1),即使是一毫秒,也可以保证executor.getActiveCount()的结果大概率为0
        // getPoolSize的大小可以认为就是线程池内部Work的个数,闲置的Work/线程处于等待状态
        System.out.println("\n线程池B中活动线程数:" + executor.getActiveCount() + ",预热成功数:" + startCount + ",存活线程数:" + executor.getPoolSize());
        // 空任务,但这也会触发线程池中一个新线程的的诞生,因为getPoolSize=2 < setCorePoolSize=4
        executor.execute(() -> {});
        // 结果为3 4
        System.out.println("存活线程数:" + executor.getPoolSize() + ",核心线程值:" + executor.getCorePoolSize());
        // 让线程池达到最大值,这会丢弃后面的两个任务
        // 打印10
        for (int i = 0; i < 10; i ++) {
            final int temp = i;
            // 随机打印0~9中的随机7个数,即最大任务数 + 任务队列数 = 6 + 1 = 7
            executor.execute(() -> {
                try {
                    // 延长耗时
                    System.out.print(temp);
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        // 为6 4 6
        System.out.println("\n存活线程数:" + executor.getPoolSize() + ",核心线程值:" + executor.getCorePoolSize() + ",历史最大线程值" + executor.getLargestPoolSize());
        // 让非工作线程闲置时间超过运行的最大闲置时间,从而让系统回收非核心线程
        Thread.sleep(4500);
        // 为4 4 6
        System.out.println("\n存活线程数:" + executor.getPoolSize() + ",核心线程值:" + executor.getCorePoolSize() + ",历史最大线程值" + executor.getLargestPoolSize());
        // 让核心线程也可以被系统回收
        executor.allowCoreThreadTimeOut(true);
        Thread.sleep(4500);
        // 为0 4 6
        System.out.println("\n存活线程数:" + executor.getPoolSize() + ",核心线程值:" + executor.getCorePoolSize() + ",历史最大线程值" + executor.getLargestPoolSize());
        // 不等待任务执行完毕就关闭线程池
        executor.shutdownNow();

        // 也可以使用JDK默认给的线程池,虽然不建议
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        Future future = executorService.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                return "面向异步编程!";
            }
        });
        System.out.println(future.get());

        i = 0;
        // 测试,假设是无界队列(未指定大小),看是否能无限容纳对象任务
        ExecutorService executorService1 = new ThreadPoolExecutor(2, 2, 1, TimeUnit.MINUTES, new LinkedBlockingDeque<>(), new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r, "第二组线程" + i++);
            }
        }, new ThreadPoolExecutor.AbortPolicy());
        for (int j = 0; j < 6; j++) {
            executorService1.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + "打印了这条信息1111");
                    ThreadUtil.sleep(200);
                }
            });
        }
        // 上面的答案是确定的,再未明确指定 LinkedBlockingDeque 大小的时候,队列为int最大值,因此可容纳巨多个任务,从而可以造成操作系统异常
        // 测试一下,假设一个线程在执行任务过程中,抛出异常(未被正确捕捉)
        executorService1.execute(new Runnable() {
            @Override
            public void run() {
                ThreadUtil.sleep(100);
                System.out.println("======================");
                throw new IllegalArgumentException("简单测试");
            }
        });
        ThreadUtil.sleep(1000);
        System.out.println("\n存活线程数:" + executor.getPoolSize() + ",核心线程值:" + executor.getCorePoolSize() + ",历史最大线程值" + executor.getLargestPoolSize());
        for (int j = 0; j < 6; j++) {
            executorService1.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + "打印了这条信息2222");
                    ThreadUtil.sleep(200);
                }
            });
        }
        // 从输出结果,我们不难发现,线程池将抛出异常的线程进行销毁,并重新new出了一个新线程
        // 所以说,如果使用线程池不恰当的话,可能会丢失某些关键信息
    }
}
// 为节省版面,省略输出

阻塞队列扩展

public class BlockQueueTest {

    public static void main(String[] args) throws InterruptedException {
        // 虽然这里是 new ArrayBlockingQueue,但下面操作是有通用性
        BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(4);

        // 入队失败则抛异常,成功返回true,否则抛IllegalStateException
        blockingQueue.add("1");
        // 立马返回结果,成功true,失败false
        boolean success = blockingQueue.offer("2");
        System.out.println(success);
        // 阻塞于插入操作
        blockingQueue.put("3");
        // offer的超时返回
        success = blockingQueue.offer("4", 4, TimeUnit.SECONDS);
        System.out.println(success);

        /// blockingQueue.clear();
        // 对应add,成功返回移除对象,否则抛NoSuchElementException
        String obj = blockingQueue.remove();
        System.out.println(obj);
        // 对应offer
        blockingQueue.poll();
        // 对应put
        blockingQueue.take();
        // 对应offer的超时返回
        blockingQueue.poll(4, TimeUnit.SECONDS);

        // 获取头部元素,但不移除,成功返回头部元素,否则抛NoSuchElementException
        obj = blockingQueue.element();
        System.out.println(obj);
        // 获取头部原始,但不移除,存在返回头部元素,否则返回null
        // 这里又有个疑问了,是否任务可以为null呢
        // 为了不引发歧义,所以这里的值同ConcurrentHashMap,一样不允许为null
        obj = blockingQueue.peek();
        System.out.println(obj);
    }
}
// 为节省版面,省略输出

4、Tomcat线程池

面试官:知道tomcat线程池和jdk线程池的区别吗?

具体如下:

  1. Jetty采用自研线程池方案,Tomcat采用的是扩展线程池方案

  2. tomcat线程池继承于jdk线程池,重写了少量代码,使其更适合Web服务器这种io密集型任务场景;原生jdk线程池更适合cpug密集型任务

  3. 重写了任务队列TaskQueue,任务请求流程较原生会有不同:请求进来时会优先创建并分配线程而不是进入等待队列,如果任务队列满了则执行抛弃策略,关键代码片段如下

// 原生线程池逻辑:核心线程池 -> 任务队列 -> 最大线程 -> 抛弃策略
// java.util.concurrent.ThreadPoolExecutor#execute方法核心逻辑
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
    if (addWorker(command, true))
        return;
    c = ctl.get();
}
// 注意这里的 offer 方法,TaskQueue重写后,如果没有到最大线程数,直接新建线程
if (isRunning(c) && workQueue.offer(command)) {
    int recheck = ctl.get();
    if (! isRunning(recheck) && remove(command))
        reject(command);
    else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
}
else if (!addWorker(command, false))
    reject(command);


// 来自类 org.apache.tomcat.util.threads.TaskQueue 
// 继承于 java.util.concurrent.LinkedBlockingQueue
@Override
public boolean offer(Runnable o) {
    // 如果线程池为空,直接入队列
    if (parent==null) return super.offer(o);
    // 当前线程池线程数 = 最大线程池数,进入队列等待
    if (parent.getPoolSize() == parent.getMaximumPoolSize()) return super.offer(o);
    // 已提交并执行中的任务数 <= 当前线程池线程数,进入队列等待
    if (parent.getSubmittedCount()<=(parent.getPoolSize())) return super.offer(o);
    // 当前线程池线程数 < 最大线程数,返回false (即创建新线程)
    if (parent.getPoolSize()<parent.getMaximumPoolSize()) return false;
    // 其他条件进去等待队列
    return super.offer(o);
}

// 来自类 org.apache.tomcat.util.threads.ThreadPoolExecutor
// 继承于 extends java.util.concurrent.ThreadPoolExecutor
// 重写 execute 方法
public void execute(Runnable command, long timeout, TimeUnit unit) {
    submittedCount.incrementAndGet();
    try {
        super.execute(command);
    } catch (RejectedExecutionException rx) {
        // 注意这里的逻辑,即使原生线程池执行了抛弃策略,这里仍会再试一次
        // 与上面offer方法逻辑一起,共同改变了原生线程池任务的执行流程
        if (super.getQueue() instanceof TaskQueue) {
            final TaskQueue queue = (TaskQueue)super.getQueue();
            try {
                if (!queue.force(command, timeout, unit)) {
                    submittedCount.decrementAndGet();
                    throw new RejectedExecutionException(sm.getString("threadPoolExecutor.queueFull"));
                }
            } catch (InterruptedException x) {
                submittedCount.decrementAndGet();
                throw new RejectedExecutionException(x);
            }
        } else {
            submittedCount.decrementAndGet();
            throw rx;
        }

    }
}

5、Lock接口

面试官:平时有使用过Lock接口下的工具类吗?或者说平时有了解java.util.concurrent包吗?

我:平时有了解过。

Lock接口的实现主要依赖于Unfase类提供的CAS(Compare And Swap)操作,CAS提供的是CPU级别的原子操作,通过这一步可以避免传统Java中的锁机制,配合线程自旋,从而实现无锁并发。

平时使用中,对重入锁ReentrantLock、读写锁ReentrantReadWriteLock使用的比较多(当前,之前提到过的阻塞队列也都是java.util.concurrent包下面的)。

重入锁ReentrantLock的特点是支持锁的重入,什么是锁的重入呢?举个例子,假设线程1获取了锁对象LockAB从而执行方法A,那么当方法A里面又需要去执行需要获取锁对象LockAB方法B,此时如果不加以干涉,线程A就会阻塞于自己。听起来是有点不可思议,这是因为传统的synchronized关键字隐式支持锁重入,大部分情况下,可以把ReentrantLock看做synchronized的另一种实现。

而读写锁ReentrantReadWriteLock的特点是对写锁的操作会阻塞读锁,而对读锁的操作不会影响写锁。当在某个读多写少时的场景下,ReentrantReadWriteLock的吞吐量是远高于ReentrantLock的。为什么呢?假设读写锁没有分离,那么每单次读、写都会阻塞其他的读、写;分离后,只有每单次写会阻塞其他的读、写。

面试官:你之前提到过Unsafe类,可以介绍一下它吗?

我:可以。

Unsafe类并不是JDK的标准,是Sun的内部实现。

Unsafe类里面的操作是非常偏底层,甚至可以直接操作硬件指令、内存地址,这是一个非常危险的操作,因此它被声明为私有方法,且唯一对外暴露的实例方法内部严格限制了只能是被系统类加载器加载。所以想要获取Unsafe这个类的实例还需要一点特殊技巧,例如字段反射。

6、Unsafe类扩展

// linux系统可通过以下命令获取包含的Unsafe类源码的src.zip
// sudo apt-get install openjdk-8-source
// 默认文件保存在这个位置 usr/lib/jvm/openjdk-8/src.zip
public class UnsafeTest {

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, NoSuchMethodException, InvocationTargetException, InstantiationException {
        // 注意不能通过方法反射,可以通过字段或私有构造方法反射
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe)field.get(null);

        Constructor<Unsafe> constructor = Unsafe.class.getDeclaredConstructor(null);
        constructor.setAccessible(true);
        unsafe = constructor.newInstance(null);
        // 获得Unsafe实例后,下面将模拟一个场景,三个线程轮流打印123 456 789
    }
}

6、其他多线程工具类

闭锁/倒计时器CountDownLatch、栅栏CyclicBarrier、Semaphore限流。

7、JMH性能测试

/**
 * 测试公平锁与非公平锁之间的差距,为避免潜在的误差,使用JMH测试
 * JMH,即Java Microbenchmark Harness,是专门用于代码微基准测试的工具套件,相关JMH知识请读者自行查阅相关资料
 * 以下配置为:
 * -为每个方法单独开一个进行进程、一个线程进行测试、,
 * -预热两次,一次预热一秒
 * -执行方法两次,一次执行一秒
 * -测试模式为平均耗时
 * -统计结果单位为毫秒
 * @author HK
 * @date 2020-05-27 20:47
 */
@Fork(1)
@Threads(1)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 2, time = 1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class LockTest {

    @Benchmark
    public static void testFairLock() throws InterruptedException {
        ReentrantLock unfairLock = new ReentrantLock(false);
        runTask(unfairLock);
    }

    @Benchmark
    public static void testUnfairLock() throws InterruptedException {
        ReentrantLock fairLock = new ReentrantLock(true);
        runTask(fairLock);
    }

    private static void runTask(ReentrantLock reentrantLock) throws InterruptedException {
        int maxThread = 10000;
        ThreadPoolExecutor executor = new ThreadPoolExecutor(20, maxThread, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200));
        CountDownLatch countDownLatch = new CountDownLatch(maxThread);
        AtomicInteger atomicInteger = new AtomicInteger(0);
        executor.prestartAllCoreThreads();
        for (int i = 0; i < maxThread; i++) {
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    reentrantLock.lock();
                    try {
                        atomicInteger.addAndGet(19);
                    } finally {
                        countDownLatch.countDown();
                        reentrantLock.unlock();
                    }
                }
            });
        }
        countDownLatch.await();
        executor.shutdown();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder().include(LockTest.class.getSimpleName()).build();
        new Runner(opt).run();
    }
}
// 输出如下
# VM invoker: /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java
# VM options: -Dvisualvm.id=15531491288691 -javaagent:/usr/dev/idea/lib/idea_rt.jar=39537:/usr/dev/idea/bin -Dfile.encoding=UTF-8
# Warmup: 2 iterations, 1 s each
# Measurement: 2 iterations, 1 s each
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: site.xiaokui.common.hk.mianshi.jmh.LockTest.testFairLock

# Run progress: 0.00% complete, ETA 00:00:08
# Fork: 1 of 1
# Warmup Iteration   1: 157.827 ms/op
# Warmup Iteration   2: 24.780 ms/op
Iteration   1: 22.087 ms/op
Iteration   2: 22.047 ms/op


Run result: 22.07 ms/op (<= 2 samples)


# VM invoker: /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java
# VM options: -Dvisualvm.id=15531491288691 -javaagent:/usr/dev/idea/lib/idea_rt.jar=39537:/usr/dev/idea/bin -Dfile.encoding=UTF-8
# Warmup: 2 iterations, 1 s each
# Measurement: 2 iterations, 1 s each
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: site.xiaokui.common.hk.mianshi.jmh.LockTest.testUnfairLock

# Run progress: 50.00% complete, ETA 00:00:06
# Fork: 1 of 1
# Warmup Iteration   1: 161.550 ms/op
# Warmup Iteration   2: 171.914 ms/op
Iteration   1: 205.747 ms/op
Iteration   2: 182.722 ms/op


Run result: 194.23 ms/op (<= 2 samples)


# Run complete. Total time: 00:00:12

Benchmark                              Mode  Samples    Score  Score error  Units
s.x.c.h.m.j.LockTest.testFairLock      avgt        2   22.067          NaN  ms/op
s.x.c.h.m.j.LockTest.testUnfairLock    avgt        2  194.235          NaN  ms/op

四、Java虚拟机

面试官:分别讲下Java的内存区域、内存回收、内存模型、G1收集器。

1、内存结构

含以下几部分(针对1.8):

  • Java堆:存放对象实例,新生代 : 老年代 = 1 : 2,Eden : Survivor1 : Survivor2 = 8 : 1 : 1
  • 元空间:1.7又称方法区,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(含运行时常量池)。也叫非堆(Non-Heap),另外在HopSpot虚拟机上,又称为永久代(Permanent Generation)。
  • 虚拟机栈:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。
  • 本地方法栈:本地方法栈与虚拟机栈所发挥的作用非常相似,它们之间的区别是本地方法栈为Native方法服务。值得一提的是,在HopSpot虚拟机中(JDK默认的虚拟机),直接把本地方法栈和虚拟机栈合二为一了。
  • 程序计数器:线程所执行的字节码的行号指示器。

Java堆和元空间为线程共享区域,其余均为线程隔离区域。

2、内存回收

内存回收关注一下3个点,哪些内存需要回收?什么时候回收?如何回收?

哪些内存需要回收?

  • 引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值减1;任何时刻计数器为0的对象就是不可能再被使用的。实现简单,但难易解决循环依赖问题。

  • 可达性分析算法:通过一系列的被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,应予以回收内存。

Java采用后者,GC Roots通俗的讲,就是运行时期方法(包含native方法)所引用的对象、类变量和类final常量所引用的对象。

什么时候会进行回收?

  • 程序调用System.gc时可以触发,也不是立即触发,只是发了个通知要触发,时机由JVM自己把握。

  • 系统自身来决定GC触发的时机(根据Eden区和From Space区内存大小来决定。当内存大小不足时,则会启动GC线程并停止应用线程)。

如何回收?

回收算法有3种:

  • 标记-清除:效率问题,标记和清除效率都不高;空间问题,会产生大量不连续的碎片。
  • 标记-复制:速度快,但浪费内存,每次只使用一半的内存空间。
  • 标记-整理:针对对象存活率较高的内存区域,避免进行较多的复制操作,同时也没有产生的不连续的空间。

现在的商业虚拟机都采用“复制算法”来回收新生代,而采用“标记-清除”或“标记-整理”算法来回收老年代

3、垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下面简要概述几种常见的垃圾收集器(以HotSpot虚拟机为例)。

收集器说明回收算法备注
Serial单线程新生代收集器,简单而高效复制“Stop The World”发明者,常用在Client模式下
ParNewSerial收集器的多线程版本复制Server模式下的虚拟机中首选的新生代收集器,除了Serial收集器之外,目前只有它能与CMS收集器配合工作
Parrallel Scavenge新生代收集器复制追求系统吞吐量可控,不追求GC停顿,主要适合用于在后台运算而不需要太多交互的任务。
Serial OldSerial Old是Serial收集器的老年代版本标记-整理单线程收集器,使用“标记-整理”算法。主要也是用于Client模式下的虚拟机
Parallel OldParrallel Scavenge老年代版本标记-整理为了匹配Parallel Scavenge收集器,使得“吞吐量优先”收集器终于有了比较名副其实的应用组合
CMS老年代收集器,配套ParNew标记-整理Concurrent Mark Sweep收集器是一种以获取最短回收停顿时间为目标的老年代收集器
G1Garbage First,老年代收集器,目标是替换CMS全局标记清除、局部标记复制(Region之间)最新一代的研究成果,目标是全面替换CMS,它确实也做到了。-XX:+UseG1GC,JDK9默认垃圾收集器

4、G1收集器

G1垃圾收集器有以下特点:

  • 也是基于分代收集(仍然有新生代和老年代),但是分代区域大小和数量不在固定,物理不连续,只保持逻辑的连续
  • 基于可变的Region,可以根据需要,随时在Eden、Survivor1、Survivor2、老年代切换
  • 有4次标记
    • 初始标记:标记一下GC RootS,需要STW。
    • 并发标记:从GC Roots开始对堆中对象进行可达性分析,与用户线程并行。
    • 最终标记:对上一阶段的动作收下尾,需要STW。
    • 筛选回收:更新Region的信息,对各个Region的回收价值和成本排序,根据用户期望的停顿时间来执行回收计划,需要STG。
  • 优缺点:
    • 优点:内存更加规整,不会产生空间碎片,避免了分配大对象时找不到连续内存空间,不得不提前触发下次GC。
    • 不足:由于跨Region引用等大量双向卡表的存在,会占用更多内存,增加程序运行时的额外负载(对内存和CPU要求高一点)。

更多参考 JVM垃圾回收—G1垃圾收集器

5、内存模型

Java内存模型是啥?

Java虚拟机定义的一种规范,来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

最底层就是CPU、CPU缓存、内存、内存高速缓存,他们的交互其实是非常复杂的。所以JVM去尝试抽象出一种Java内存模型,主内存和工作内存,去屏蔽这些底层交互的细节。重点是围绕3个特性展开的:原子性可见性有序性

其中比较需要注意的volatile关键字(可见性-使线程工作内存的失效,强制去刷主内存的值、有序性-会插入内存屏障,禁止重排序)和happen-before原则作为是否线程安全的判断依据。

6、类加载机制

面试官:类加载流程?双亲委派模型?如何打破?

类加载流程

类加载流程(按部就班开始,但并不一定按照顺序完成,相互交叉混合式进行的):

  1. 加载:通过一个类的全限定名获取字节码二进制流,然后加载进方法区,生成对应的Class对象。
  2. 验证:验证字节码二进制流的安全性。
  3. 准备:为类变量分配内存并设置初始值(静态变量),这里所说的初始值“通常情况”下是数据类型的零值。
  4. 解析:将符号引用转化成直接引用。
  5. 初始化
    1. <clinit>方法-类的初始化
      1. 准备阶段,变量已经赋过一次系统要求的初始值。
      2. 由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static代码块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
      3. 虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁、同步。
    2. <init>方法-类的实例化
      1. 再执行完<clinit>方法后,大部分情况下会紧接执行<init>方法,但不一定(两者没有必然联系)。
      2. <init>方法包含且执行顺序依次为:变量初始化 、语句块、构造方法。一个类可以没有<init>方法,但一定有<clinit>方法。
    3. 初始化触发条件
      1. 使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候、以及调用一个类的静态方法的时候。
      2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。
      3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
      4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法),虚拟机会先初始化这个主类
  6. 使用:使用阶段。
  7. 卸载:不再被使用,删除对应的class对象。

双亲委派模型

  • 对于任意一个类,都需要由它的类加载器和这个类本身一同确立其在Java虚拟机的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

  • 类加载器收到类加载请求,会优先让父类就加载。只有父类反馈无法加载时,才会自己去加载。Java类和类加载器有了优先级,保证了最基础的一些系统类能够正确、安全地被加载和使用。比如Object类在任何环境下都是同一个,用户无法改变。

  • 常见的类加载器有:

    • 启动类加载器(Bootstrap ClassLoader):加载jdk相关类库,由C++实现,是虚拟机的一部分。
    • 扩展类加载器(Extension ClassLoader):加载sun指定的扩展类库。
    • 应用程序类加载器(Application ClassLoader):加载用户类路径上指定的类库。
  • 从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器;另外一种就是所有其他的类加载器。

打破双亲委派模型

  1. xx
  2. xx

7、Javaagent

这个不一定很全,说下我自己的理解:

  1. 基于JVMTI机制实现,JVMTI(Java Virtual Machine Tool Interface)是由 Java 虚拟机提供的了一套代理程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM。本质是C语言留了一个扩展点,让我们有能力在运行前、运行中对字节码就行重写,有点类似于字节码层面的AOP。
  2. 有多种使用方式
    1. premain:可以在启动时显式指定-javaagent xxx参数,比如skywalking、ttl、SandBox,此时main方法尚未开始调用。
    2. agentmain:代码里面实现agentmain方法和补充特定配置,比如skywalking,此时main方法已完成调用。
    3. attach:通过JVM的Attach API,将Agent attach到对应Java进程上,比如arthas,此时java程序处于运行中。
    4. 但其实还有一种,一般人不知道,就是显式调用(把调用时机后置,交于用户自己掌握),此时main方法处于调用中。

五、Redis缓存

面试官:redis有哪种几种数据结构?有哪些使用场景?为什么这么快?

1、redis数据结构

类型存储值操作使用场景
STRING可以是字符串、整数或者浮点数对整个字符串或字符串的其中一部分执行操作;对整数和浮点数执行自增或者自减操作一级缓存、登录会话、接口限流、分布式锁、全局计数器、网站访问量
LIST一个链表,链表上的每个节点都包含了一个字符串从链表的两端推入或弹出元素;根据偏移量对链表进行修建;读取单个或者多个元素;根据值查找或者移除元素用户订单列表、在线用户
SET包含字符串的无序收集器,并且被包含的每个字符串都是独一无二、各不相同的添加、获取、移除单个元素;检查一个元素是否存在于集合中;计算交集、并集、差集;从集合里面随机获取元素商品筛选
HASH包含键值对的无序散列表添加、获取、移除单个键值对;获取所有键值对点赞列表、签到、打卡
ZSET字符串成员与浮点数分值之间的有序映射,元素的排列顺序由分值大小决定添加、获取,删除单个元素;根据分值范围或者成员来获取元素排行榜、热搜

2、redis为什么快

有下面4点原因:

  1. 基于内存
  2. 精心设计的数据结构,比如动态字符串、跳跃表,源码层面充分考虑了性能(比如同一种数据结构,和Java的实现细节上会有一点不一样,更契合语言特性)
  3. 单线程模型(一个工作线程,基于事件队列)
    1. 最小化由于线程创建或销毁引起的 CPU 消耗
    2. 最大限度地减少由于上下文切换引起的 CPU 消耗
    3. 减少锁开销,因为多线程应用程序需要锁来进行线程同步,这很容易出错
    4. 能够使用各种“线程不安全”命令,例如 lpush
    5. 2020年5月份,Redis正式推出了6.0版本。Redis 6.0中的多线程,也只是针对处理网络请求过程采用了多线程,而数据的读写命令,仍然是单线程处理的
  4. 基于多路复用IO模型
    1. 多个进程的IO可以注册到同一个管道上,这个管道会统一和内核进行交互。当管道中的某一个请求需要的数据准备好之后,进程再把对应的数据拷贝到用户空间中
    2. 多路指的是多条 TCP 连接, 复用指的用一个进程来处理这多条的连接(select,poll,epoll系统调用)
    3. 阻塞在select,poll,epoll这类系统调用上的,复用的是执行select,poll,epoll的线程
    4. select底层实现-数组(最大2048),poll底层实现-链表,epool底层实现-事件机制,存储结构是红黑树
    5. 你自己写过原生的http服务器就知道,用java写只有3种IO模型可以选,BIO、NIO、AIO,3种IO在代码层面的交互方式是完全不一样的

3、缓存雪崩&击穿&穿透

前置条件:数据量访问都比较大。

缓存雪崩

现象:同一时间缓存key全部失效,致使流量全部达到DB,造成系统崩溃。一般在秒杀活动、热门活动数据场景下可能出现。

解决:如下

  1. 过期时间加上随机值,保证在同一时间key不会出现大批量过期
  2. 永不过期,手动进行更新或删除

缓存穿透

现象:查询数据在缓存和数据库中都不存在,造成DB压力过大。

解决:如下

  1. 接口层添加参数校验
  2. 对查询结果临时放redis缓存,有效期设置得短一点,命中则快速返回失败
  3. 使用布隆过滤器
  4. 一般是恶意攻击,进行限流或者临时拉黑名单

缓存击穿

现象:查询缓存无数据,数据库中有数据。一般是指由于某个热点key突然过期,致使大流量瞬间打入数据库,造成DB压力过大。像在一张完整的防护面上直接击穿一个小洞洞。

解决:如下

  1. 设置热点数据永远不过期,手动进行更新或删除
  2. 互斥锁,未命中缓存则加锁,单个机器只允许一个线程去加载数据,其他线程等待(可以是分布式锁,也可以是Java对象锁)

4、分布式锁

分布式锁内容可以讲很多,这里只列下关键点:

  • 简单场景:

    1. 使用原子操作 Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) 进行锁的获取
    2. 如果业务需要显式释放锁,那么value可以加上唯一值(uuid),删除前查一次再删(这里最好是用lua脚本,因为不是原子性)
    3. 把操作封装成一个Lua脚本,redis的eval/evalsha命令来运行,如论如何都是一次执行的原子操作
  • 复杂场景:

    1. 上面还可能存在一种场景:锁过期释放,但业务还没执行完
    2. 使用Redis分布式锁开源框架Redisson,提供了多种可选锁类型使用(基于Watch Dog),如普通锁(RedissonLock)、读写锁(RedissonReadLock)、公平锁(RedissonFairLock)、联机锁(RedissonMultiLock)、红锁(RedLock)

参考链接:七种方案!探讨Redis分布式锁的正确使用姿势

六、分布式/微服务

1、CAP

面试官:能说下分布式里面的CAP理论吗?

我:可以。

  • C指的是Consistency,即一致性,所有节点拥有最新数据。
  • A指的是Availability,即可用性,节点具有高可用性。
  • P指的是Partition tolerance,即分区容错性,分区之间网络不可达。

由于分区容错性是客观存在的,因此三者最多只能兼顾两者,即保证一致性,就要丢弃可用性;保证可用性,就要丢弃一致性。这里的一致性指的是强一致性、可用性指的是高可用性

注意:这里的强和高的含义指的是每一个节点都必须是数据最新、可用,实际应用中可以使用最终一致性、弱可用性替换。

在Java分布式的体系内,有Zookeeper可以保证强一致性,即支持CP;Eureka可以保证高可用性,即支持AP。

面试官:那么,AP和CP怎么选呢?

我:根据业务场景来,事先说明一下,这里的前置条件是出现了P,即单个节点与其他节点出现了网络断层。

强一致性场景:例如金融行业里面跟个人资产挂钩的服务,这时我们允许单个系统的宕机,但是不允许对外服务的单个系统出现用户资产数据的错误。

高可用性场景:照顾用户体验,例如社交服务、论坛服务、游戏服务等,这时我们我们允许系统出现短暂的数据延迟,但要保证对外服务的可用性。

2、服务注册中心

面试官:有了解过Eureka、Zookeeper吗?二者有什么不同吗?

我:有一定的了解。

Eureka是Spring Cloud体系内使用的服务注册中心,保证AP;Zookeeper是Kafka默认的服务注册中心,保证CP;它们两者都是作为服务注册中心被客户端使用的,至于到底选哪个,存在即合理,根据公司的技术栈选择即可。

3、负载均衡

面试官:有了解过负载均衡吗?例如Ribbon、Nginx、F5。

我:有一定的了解。

F5是硬件层面的负载均衡、Nginx是应用层服务端层面的负载均衡、Ribbon是应用层客户端层面的负载均衡。

这里的客户端层面,可以这样理解:服务A内需要调用服务B,但此时有多个可以提供服务B的服务,例如B1、B2,这时发生的负载均衡就是客户端负载均衡。

4、分布式事务

面试官:如何实现分布式事务?

我:分布式事务其实是单机单库事务的衍生,用于解决跨应用/跨库之间的数据问题。

1、2PC

两阶段提交(Two-phase Commit,2PC),引入一个事务管理器来作为事务的协调者,分为 预提交实际提交 两个阶段。

  • 在程序执行复杂数据操作时,开启事务,SQL语句执行完后,先不进行commit,先将执行的结果告知给事务管理器

  • 这个时候,再执行其他数据库的SQL语句。当这个事务中,所有的SQL语句全部执行完毕后,即事务管理器已经得到所有此次事务中SQL语句的执行结果后。

  • 再集体通知所有的数据库进行commit,任何一个语句执行失败,事务管理器就会通知所有数据库进行rollback。

2PC思想是分布式事务中核心思想,其他的分布式事务解决方案都是基于2PC的。值得一提的是,分布式事务并不能保证100%的可用,这里举个例子便于说明,如下

**场景:假设某商场有一个订单服务,支付完成后需要将订单数据库订单置为已支付,且将商品库存数据库中商品数减1。**那么整体流程大概是这样的:

  1. 订单数据库开启式事务,将订单置为已支付后,先不提交,将结果告知事务管理器
  2. 然后开启商品库存数据库事务,减库存后,先不提交,将结果告知事务管理器
  3. 当订单、库存都修改成功,事务管理器之中都收到了执行成功信息,确认好了本次事务中的所有语句都执行完成后,立即通知所有数据库进行 commit 操作。任何一个语句执行失败,事务管理器就会通知所有数据库进行rollback。

以上是理想情况下,如果细分的,又可以有如下几个调用链:

  • 单个服务向事务管理器发送预提交,这一步可能失败。
  • 事务管理向单个服务发送提交/回滚,这一步可能失败。

通过以上分析,可以得出,分布式事务其实也不是100%成功的,但是可以尽可能地提高成功概率

在预提交成功后,可以显而易见的得出几个结论:

1、所有SQL都执行成功了,所以数据库在执行SQL语句时状态是正常的、服务器和数据库之间的网络也是正常的

2、由于库存库的SQL执行成功,所以库存够减,不可能出现库存不够的问题

那么,后续的实际提交应该不会出什么问题。这里又有人问了,如果此时的实际提交失败了怎么办,如果此时订单数据库挂了或者商品库存数据库挂了或者事务协调器挂了或网络原因没有成功通知,那怎么办?

这种情况出现确实没有别的办法。一般来说都会记日志打log、重试机制,之后要么后台通过定时任务补偿,要么人工介入

2、TCC

TCC(Try-Confirm-Cancel),两阶段补偿型分布式事务解决方案,是现在的互联网大厂主流的分布式事务解决方案之一。而TCC的原理相比于2PC,有这样一种行为上的变更:

  • 2PC的行为是: 1.预提交 ,2.实际提交

  • TCC将其改为了: 1.预留资源, 2.确认资源

TODO

5、分布式ID

七、算法

点击这里查看个人对于《剑指Offer》66题的Java(一刷)、Python(二刷)题解。

八、Nginx

# www.xiaokui.site中的nginx配置,非最新,但大部分逻辑相同
# 对外只暴露80、443端口
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

# Load dynamic modules. See /usr/share/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;

events {
    worker_connections 1024;
}

http {
    log_format  main  '$remote_addr $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$request_time $upstream_response_time"';

    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 2048;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    # Load modular configuration files from the /etc/nginx/conf.d directory.
    # See http://nginx.org/en/docs/ngx_core_module.html#include
    # for more information.
    include /etc/nginx/conf.d/*.conf;

    # 文件服务、短链接服务
    server {
        listen 80;
        server_name s.xiaokiu.site www.s.xiaokui.site;
        rewrite ^(.*)$ https://s.xiaokui.site$1 permanent;
    }

    # 博客服务
    server {
        listen 80;
        server_name blog.xiaokui.site www.blog.xiaokui.site;
        rewrite ^(.*)$ https://blog.xiaokui.site$1 permanent;
    }

    # 后备服务
    server {
        listen 80;
        server_name hb.xiaokui.site www.hb.xiaokui.site;
        rewrite ^(.*)$ https://hb.xiaokui.site$1 permanent;
    }

    # 其他服务
    server {
        listen 80;
        server_name other.xiaokui.site www.other.xiaokui.site;
        rewrite ^(.*)$ https://other.xiaokui.site$1 permanent;
    }

    server {
        listen 80;
        server_name xiaokui.site www.xiaokui.site;
        rewrite ^(.*)$ https://xiaokui.site$1 permanent;
    }

    # 文件服务、短链接服务 ssl
    server {
        listen 443 ssl;
        server_name s.xiaokiu.site www.s.xiaokui.site;
        ssl_certificate "/etc/pki/fullchain.crt";
        ssl_certificate_key "/etc/pki/private.pem";
        proxy_connect_timeout 1s;
        proxy_read_timeout 10s;
        proxy_send_timeout 10s;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        location / {
            proxy_pass http://localhost:8080/filer/;

        }
    }

    # 博客服务 ssl
    server {
        listen 443 ssl;
        server_name blog.xiaokui.site www.blog.xiaokui.site;
        ssl_certificate "/etc/pki/fullchain.crt";
        ssl_certificate_key "/etc/pki/private.pem";
        proxy_connect_timeout 1s;
        proxy_read_timeout 10s;
        proxy_send_timeout 10s;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        location / {
            proxy_pass http://localhost:9090;
        }
    }

    # 后备服务 ssl
    server {
        listen 443 ssl;
        server_name hb.xiaokui.site www.hb.xiaokui.site;
        ssl_certificate "/etc/pki/fullchain.crt";
        ssl_certificate_key "/etc/pki/private.pem";
     
        location / {
            root /xiaokui/root;
            index index.html index.htm;
            # 如果是vue单页面应用
            # try_files $uri $uri/ /index.html;
            expires -1;
        }
    }

    # 其他服务 ssl
    server {
        listen 443 ssl;
        server_name other.xiaokui.site www.other.xiaokui.site;
        ssl_certificate "/etc/pki/fullchain.crt";
        ssl_certificate_key "/etc/pki/private.pem";

        proxy_connect_timeout 1s;
        proxy_read_timeout 10s;
        proxy_send_timeout 10s;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        location / {
            proxy_pass http://localhost:9090;
        }
    }

    server {
        listen 443 ssl;
        server_name xiaokui.site www.xiaokui.site;
        ssl_certificate "/etc/pki/www.xiaokui.site.pem";
        ssl_certificate_key "/etc/pki/www.xiaokui.site.key";

        location / {
            # proxy_pass http://localhost:9090;
	        return 404;
        }
    }
}

九、Linux

#!/bin/bash
# @author HK
# @date 2020-03-21
# @version 1.1
# 个人远程部署jar包的一个脚本

readonly root_jar_path="/home/hk/ROOT.jar"
readonly remote_ip_list=("133.33.33.33")
readonly remote_user=("root")
readonly remote_passwd=("xxxx")
readonly remote_target_dir="/xiaokui"
basepath=$(cd .; pwd)

function startScp() {
    if test -e $root_jar_path
    then
       echo "$root_jar_path文件存在,开始复制到远程服务器"
    else
        echo "$root_jar_path文件不存在,请确认存在后再执行本脚本"
        exit 500    
    fi
    for((i=0;i<${#remote_ip_list[@]};i++))
    do
        ip=${remote_ip_list[$i]}
        if test -n "$ip"
        then
    	    expect -c "
            set timeout -1;
            spawn scp ${root_jar_path} ${remote_user[$i]}@${ip}:${remote_target_dir}
    	    expect {
                "*yes*" {send "yes"\r;exp_continue}
                "*pass*" {send "${remote_passwd[$i]}"\r}
            }
            interact;"
            echo "复制到远程服务器$ip:${remote_target_dir[$i]}成功"	
        fi
    done
    return 0
}


function login() {
    if test ${#remote_ip_list[*]} -eq 1
    then
        echo "尝试登录远程服务器${remote_user[0]}"
        filename=${root_jar_path##*/}    	
	    expect -c "
        set timeout -1;
        spawn ssh ${remote_user[0]}@${remote_ip_list[0]}
	    expect {
            "*yes*" {send "yes"\r;exp_continue}
            "*pass*" {send "${remote_passwd[0]}"\r}
        }
        interact;"
    fi
}

function runJar() {
    if test ${#remote_ip_list[*]} -eq 1
    then
        echo "尝试登录远程服务器${remote_user[0]},并自动执行部署脚本"
        filename=${root_jar_path##*/}    	
        ssh ${remote_user[0]}@${remote_ip_list[0]} 'bash -s' < $basepath/_run-jar.sh ${remote_target_dir[0]}/$filename $1
    fi 
}

case $1 in
    go)
        startScp
        runJar nohup
        ;;
    scp)
        startScp
        ;;
    scp-login)
        startScp
        login
        ;;
    scp-run)
        startScp
        runJar
        ;;
    nohup-run)
        startScp
        runJar nohup
        ;;
    *)
        echo "后面参数为:{scp|scp-login)|scp-run|nohup-run}"
esac

#!/usr/bin/bash 
# @author HK
# @date 2020-03-21
# @version 1.1
rootJarPath=$1
nohup=$2

if test -z "$rootJarPath"
then
    rootJarPath="/xiaokui/ROOT.jar"
fi

pid=$(ps -aux | grep -E "ROOT.war|ROOT.jar" | grep -v grep | grep -v 'bash -s' | awk '{print $2}')
echo "准备部署应用,找到目标PID:$pid"
function runApp() {
    echo "开始部署应用,路径为$rootJarPath,运行目录为${rootJarPath%/*},nohup=$nohup"
    cd "${rootJarPath%/*}"
    if test -n "$nohup"
    then
        nohup java -jar $rootJarPath --server.port=9090
    else
        java -jar $rootJarPath --server.port=9090
    fi    
}


if test ${#pid[*]} -gt 1
then
    echo "自动部署应用失败,请手动部署"
    exit 500
fi

if test -z "$pid"
then
    runApp
    exit 0
fi

if test ${#pid[*]} -eq 1
then
    echo "杀掉原有PID:${pid[@]},重新开始部署"
    kill -9 $pid
    runApp
    exit 0
fi

十、Spring

1、如何解决循环依赖

面试官:Spring是如何解决循环依赖的?

我:这个可以从头说起,我们先要搞清楚一个类的加载时机和加载顺序,以下几种情况会触发类的加载

  • 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令最常见的Java代码场景是:new一个对象、读取或设置一个类的静态变量(final除外)、以及调用一个类的静态方法
  • 使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行初始化,则需要先触发其初始化
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 当虚拟机启动时,用户需要指定一个执行的主类(包含main方法),虚拟机会先初始化这个主类

显而易见,Spring由于使用发射去初始化bean对象,属于上面的第二种。我们再来看下类加载的具体的一个顺序,大致是下面一个过程

  • 加载二进制class字节流,读入内存并生成相应的内部数据结构
  • 对加载数据进行验证,确认class数据是安全、正确、且有效的
  • 类变量赋初始值(final除外)
  • 对类中的字面符号引用进行解析
  • clinit方法收集所有对类变量的赋值语句(含静态代码块),合并执行(可以没有clinit方法)
  • 执行init方法,依次对类实例进行初始化,初始化顺序遵循:变量初始化语句块构造方法
  • 一个类可以没有clinit方法,但一定有init方法,且clinit与init方法并没有严格的先后关系,只是通常情况下,clinit先于init方法

从上我们可以清晰的得知一个类的整个初始化流程。这里我们需要注意的是:当使用Spring时,通过反射去初始化一个类的时候,首先需要确定是哪个构造器,其次再确定需要处理的反射字段与反射方法

这里的关键点在于需要区别类的初始化、bean的实例化、bean的初始化,一般来讲类的初始化可以大体上等同于bean的实例化

注意,这里并没有明确的概念规定,只是通常书面层上认为有这个几个bean周期

  • bean实例化之前
  • bean实例化,即构造器的初始化
  • bean实例化之后
  • 填充属性(含字段、方法)
  • bean初始化开始
  • bean初始化
  • bean初始化结束
  • 销毁bean

面试官:你好像还没有回答我的问题

我:是的,下面是我的总结性回答,省略500字....

2、一些代码验证

验证clinit与init的顺序关系

public class InitClass {

    public static void main(String[] args){
        // 输出为 2 3 a=100, b=0 1 4 
        f1();
    }

    static InitClass javaTest = new InitClass();

    static {
        System.out.print("1 ");
    }

    {
        System.out.print("2 ");
    }

    InitClass(){
        System.out.print("3 ");
        System.out.print("a=" + a + ", b=" + b + " ");
    }

    public static void f1(){
        System.out.print("4 ");
    }

    int a = 100;
    static int b = 200;
}

验证Spring中bean的生命周期

public class BeanLifeCycleTest {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(BeanLifeCycleTest.class);
        context.registerBeanDefinition("testBean", new RootBeanDefinition(TestBean.class));
        context.getBean("testBean");
    }

    private static class TestBean implements InitializingBean, DisposableBean {
        @Override
        public void afterPropertiesSet() throws Exception {
            System.out.println("7:F1:InitializingBean:开始调用对象的方法afterPropertiesSet");
        }

        @Override
        public void destroy() throws Exception {
            System.out.println("F2:DisposableBean:销毁bean");
        }
    }

    @Bean
    public BeanPostProcessor fullyBeanPostProcessor() {
        return new E();
    }

    private static class A implements BeanPostProcessor {
        // 初始化前后的后处理
        @Override
        public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
            // 该方法在bean实例化完毕(且已经注入完毕),在afterPropertiesSet或自定义init方法执行之前
            System.out.println("6:A1:BeanPostProcessor:实例化完成,初始化开始");
            return bean;
        }

        @Override
        public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
            // 在afterPropertiesSet或自定义init方法执行之后
            System.out.println("8:A2:BeanPostProcessor:初始化结束");
            return bean;
        }
    }

    private static class B extends A implements InstantiationAwareBeanPostProcessor {
        // 实例化前后的后处理
        @Override
        public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) throws BeansException {
            // 这个方法用来在对象实例化前直接返回一个对象(如代理对象)来代替通过内置的实例化流程创建对象;
            System.out.println("1:B1:InstantiationAwareBeanPostProcessor:实例化之前");
            return null;
        }

        @Override
        public boolean postProcessAfterInstantiation(Object bean, String beanName) throws BeansException {
            // 在对象实例化完毕,执行populateBean之前,如果返回false则spring不再对对应的bean实例进行自动依赖注入。
            System.out.println("4:B2:InstantiationAwareBeanPostProcessor:实例化完成之后");
            return true;
        }

        @Override
        public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) throws BeansException {
            // 这里是在spring处理完默认的成员属性,应用到指定的bean之前进行回调,可以用来检查和修改属性,最终返回的PropertyValues会应用到bean中
            // @Autowired、@Resource等就是根据这个回调来实现最终注入依赖的属性的。
            System.out.println("5:B3:InstantiationAwareBeanPostProcessor:开始注入/填充属性");
            return pvs;
        }
    }

    private static class C extends B implements SmartInstantiationAwareBeanPostProcessor {
        @Override
        public Class<?> predictBeanType(Class<?> beanClass, String beanName) throws BeansException {
            // 这个接口主要是spring框架内部来使用
            // 用来返回目标对象的类型(比如代理对象通过raw class获取proxy type 用于类型匹配)
            System.out.println("8:C1:SmartInstantiationAwareBeanPostProcessor:获取bean时,预测Bean类型");
            return null;
        }

        @Override
        public Constructor<?>[] determineCandidateConstructors(Class<?> beanClass, String beanName) throws BeansException {
            // 这里提供一个拓展点用来解析获取用来实例化的构造器(比如未通过bean定义构造器以及参数的情况下,会根据这个回调来确定构造器)
            System.out.println("2:C2:SmartInstantiationAwareBeanPostProcessor:确定候选构造器");
            return null;
        }

        @Override
        public Object getEarlyBeanReference(Object bean, String beanName) throws BeansException {
            // 获取要提前暴露的bean的引用,用来支持单例对象的循环引用(一般是bean自身,如果是代理对象则需要取用代理引用)
            System.out.println("C3");
            return null;
        }
    }

    private static class D extends C implements  MergedBeanDefinitionPostProcessor {
        @Override
        public void postProcessMergedBeanDefinition(RootBeanDefinition beanDefinition, Class<?> beanType, String beanName) {
            // 在bean实例化完毕后调用 可以用来修改merged BeanDefinition的一些properties 或者用来给后续回调中缓存一些meta信息使用
            // 这个算是将merged BeanDefinition暴露出来的一个回调
            System.out.println("3:D1:MergedBeanDefinitionPostProcessor:处理MergedBeanDefinition");
        }
    }

    private static class E extends D implements DestructionAwareBeanPostProcessor {
        @Override
        public void postProcessBeforeDestruction(Object bean, String beanName) throws BeansException {
            // 这里实现销毁对象的逻辑
            System.out.println("E1:DestructionAwareBeanPostProcessor:" + beanName);
        }

        @Override
        public boolean requiresDestruction(Object bean) {
            // 判断是否需要处理这个对象的销毁
            System.out.println("9:E2:DestructionAwareBeanPostProcessor:" + bean);
            return true;
        }
    }
}

日志输出如下

17:46:09.049 [ain][TRACE] context.support.AbstractApplicationContext 592: Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@2d363fb3, started on Sat May 09 17:46:09 CST 2020
17:46:09.083 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
17:46:09.084 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
17:46:09.152 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor' to allow for resolving potential circular references
17:46:09.156 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
17:46:09.298 [ain][TRACE] context.annotation.ConfigurationClassBeanDefinitionReader 284: Registering bean definition for @Bean method xiaokui.test.BeanLifeCycleTest.fullyBeanPostProcessor()
17:46:09.313 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerProcessor'
17:46:09.313 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'org.springframework.context.event.internalEventListenerProcessor'
17:46:09.315 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'org.springframework.context.event.internalEventListenerProcessor' to allow for resolving potential circular references
17:46:09.317 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'org.springframework.context.event.internalEventListenerProcessor'
17:46:09.318 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerFactory'
17:46:09.318 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'org.springframework.context.event.internalEventListenerFactory'
17:46:09.319 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'org.springframework.context.event.internalEventListenerFactory' to allow for resolving potential circular references
17:46:09.319 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'org.springframework.context.event.internalEventListenerFactory'
17:46:09.321 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
17:46:09.322 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
17:46:09.327 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor' to allow for resolving potential circular references
17:46:09.328 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
17:46:09.328 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
17:46:09.328 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
17:46:09.335 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor' to allow for resolving potential circular references
17:46:09.336 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
17:46:09.336 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'fullyBeanPostProcessor'
17:46:09.337 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'fullyBeanPostProcessor'
17:46:09.340 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'beanLifeCycleTest'
17:46:09.340 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'beanLifeCycleTest'
17:46:09.350 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'beanLifeCycleTest' to allow for resolving potential circular references
17:46:09.350 [ain][INFO] context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker 330: Bean 'beanLifeCycleTest' of type [xiaokui.test.BeanLifeCycleTest] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
17:46:09.351 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'beanLifeCycleTest'
17:46:09.361 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'fullyBeanPostProcessor' to allow for resolving potential circular references
17:46:09.363 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'fullyBeanPostProcessor'
17:46:09.366 [ain][TRACE] context.support.AbstractApplicationContext 753: No 'messageSource' bean, using [Empty MessageSource]
17:46:09.369 [ain][TRACE] context.support.AbstractApplicationContext 776: No 'applicationEventMulticaster' bean, using [SimpleApplicationEventMulticaster]
17:46:09.371 [ain][TRACE] factory.support.DefaultListableBeanFactory 848: Pre-instantiating singletons in org.springframework.beans.factory.support.DefaultListableBeanFactory@61f8bee4: defining beans [org.springframework.context.annotation.internalConfigurationAnnotationProcessor,org.springframework.context.annotation.internalAutowiredAnnotationProcessor,org.springframework.context.annotation.internalCommonAnnotationProcessor,org.springframework.context.event.internalEventListenerProcessor,org.springframework.context.event.internalEventListenerFactory,beanLifeCycleTest,fullyBeanPostProcessor]; root of factory hierarchy
17:46:09.372 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
17:46:09.372 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
17:46:09.372 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
17:46:09.372 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'org.springframework.context.event.internalEventListenerProcessor'
17:46:09.373 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'org.springframework.context.event.internalEventListenerFactory'
17:46:09.373 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'beanLifeCycleTest'
17:46:09.373 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'fullyBeanPostProcessor'
17:46:09.380 [ain][TRACE] context.event.EventListenerMethodProcessor 166: No @EventListener annotations found on bean class: xiaokui.test.BeanLifeCycleTest
17:46:09.387 [ain][TRACE] context.event.EventListenerMethodProcessor 166: No @EventListener annotations found on bean class: xiaokui.test.BeanLifeCycleTest$E
17:46:09.389 [ain][TRACE] context.support.AbstractApplicationContext 802: No 'lifecycleProcessor' bean, using [DefaultLifecycleProcessor]
17:46:09.390 [ain][TRACE] factory.support.AbstractBeanFactory 257: Returning cached instance of singleton bean 'lifecycleProcessor'
17:46:09.396 [ain][TRACE] core.env.PropertySourcesPropertyResolver 82: Searching for key 'spring.liveBeansView.mbeanDomain' in PropertySource 'systemProperties'
17:46:09.396 [ain][TRACE] core.env.PropertySourcesPropertyResolver 82: Searching for key 'spring.liveBeansView.mbeanDomain' in PropertySource 'systemEnvironment'
17:46:09.396 [ain][TRACE] core.env.PropertySourcesPropertyResolver 96: Could not find key 'spring.liveBeansView.mbeanDomain' in any property source
17:46:09.413 [ain][TRACE] io.support.SpringFactoriesLoader 100: Loaded [org.springframework.beans.BeanInfoFactory] names: [org.springframework.beans.ExtendedBeanInfoFactory]
17:46:09.432 [ain][DEBUG] factory.support.DefaultSingletonBeanRegistry 213: Creating shared instance of singleton bean 'testBean'
17:46:09.432 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 482: Creating instance of bean 'testBean'
1:B1:InstantiationAwareBeanPostProcessor:实例化之前
2:C2:SmartInstantiationAwareBeanPostProcessor:确定候选构造器
3:D1:MergedBeanDefinitionPostProcessor:处理MergedBeanDefinition
17:46:09.444 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 585: Eagerly caching bean 'testBean' to allow for resolving potential circular references
4:B2:InstantiationAwareBeanPostProcessor:实例化完成之后
5:B3:InstantiationAwareBeanPostProcessor:开始注入/填充属性
6:A1:BeanPostProcessor:实例化完成,初始化开始
17:46:09.445 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 1841: Invoking afterPropertiesSet() on bean with name 'testBean'
7:F1:InitializingBean:开始调用对象的方法afterPropertiesSet
8:A2:BeanPostProcessor:初始化结束
9:E2:DestructionAwareBeanPostProcessor:xiaokui.test.BeanLifeCycleTest$TestBean@43d7741f
17:46:09.445 [ain][TRACE] factory.support.AbstractAutowireCapableBeanFactory 519: Finished creating instance of bean 'testBean'

十一、经验分享

笔者的亲身经历,以身犯险、独闯龙潭,特此记录。

1、腾讯一面(20201109)

1、概述

时长在35分钟左右,应聘的小鹅拼拼Java开发,实际为go开发,我要求现场面试,面试地点在深南大道大族大厦8楼。

我提前半个小时到达了指定地点,然后询问了边上的一个女同学,跟她说我是来面试的,她叫我到大厅去等,害我白等了一个小时。

直到一个小时后,面试官打来电话,问我来了没有,我说我提前半个小时就到了,然后他从另一边找到了我,而后开始面试。

2、面试体验

这次面试体验其实不是很好,具体表现如下

  • 先是访客码忘记给我发(疫情期间进入腾讯大楼需要身份证 + 短信访客码,我是面试前半个小时硬打电话找到人事,让她给我补发的)

  • 二是,全程没有人接应,我愣是一个人进去,在大厅坐了一个小时,如果面试官还不来找我的话,我估计是要走了,这不是搞人心态吗?

  • 除开面试官外,其他人给我的感觉,就是很敷衍,完全没有我心目中的大厂应该有的样子,就算拿我刷KPI,不知道来者是客吗?

  • 走的时候,面试官送我出了门,然后我自己坐电梯下来了。虽然知道自己表现不好,但感受到了尊重

  • 10点钟看见很多员工陆续赶了过来,办公环境光线有点暗,人有点挤,办公环境没现在公司的办公环境好,不管是从全局还是局部(说出了你可能不信)

  • 最后面试官问我,你是没有准备好吗?我没有回答,我让他失望了,哈哈哈(其实我也没抱太大希望,只是想去外面开开眼界,基本都是临场发挥)

3、面试内容

话不多说,这次面试主要问了以下内容:

  • 介绍以下你自己
  • 问你所做过的项目,有哪些技术难点,如何解决
  • 如何保证数据的一致性、幂等性( 帮助链接
  • 问数据库,事务的隔离级别,行多版本控制机制
  • 为什么用Flink,Flink的几种时间机制、Sql标准
  • 有了解Spark吗
  • 有了解Go吗

4、笔试题

将两个n位只含数字的字符串相加,输出结果,现场手写(面试官问我有没有带电脑,我说没提前跟我说,没写出来)。

package real;

/**
 * 将两个n位只含数字的字符串相加
 *
 * @author HK
 * @date 2020-11-11 15:13
 */
public class Main1 {

    static boolean isDebug = true;

    /**
     * 先取的作为比较基准数
     *
     * @param str1 如  789
     * @param str2 如 9876
     */
    public static String addStr(String str1, String str2) {
        String maxStr = str1.length() >= str2.length() ? str1 : str2;
        String minStr = str1 == maxStr ? str2 : str1;
        if (isDebug) {
            System.out.println("maxStr=" + maxStr + ", minStr=" + minStr);
        }
        // 进位标志
        boolean needAddOne = false;
        String targetStr = new String();
        for (int i = minStr.length() - 1; i >= 0; i--) {
            // 这里转int有两种,一是以字符0作为标准,而是 Integer.parseInt(),本质都是基于第一种
            int int1 = minStr.charAt(i) - '0';
            int int2 = maxStr.charAt(i + (maxStr.length() - minStr.length())) - '0';
            int intSum = int1 + int2;
            // 上一次计算需要进位
            if (needAddOne) {
                intSum += 1;
            }
            // 需进位
            if (intSum > 9) {
                intSum -= 10;
                needAddOne = true;
            } else {
                needAddOne = false;
            }
            targetStr = intSum + targetStr;
            if (isDebug) {
                System.out.println("int1=" + int1 + ", int2=" + int2 + ", intSum=" + intSum + ", needAddOne=" + needAddOne + ", targetStr=" + targetStr);
            }
        }
        if (isDebug) {
            System.out.println("执行短位加运行后targetStr=" + targetStr + ",是否需要进位=" + needAddOne);
        }
        // 如果不需要进位, 如 1234 + 4321、123 + 1
        if (!needAddOne) {
            return maxStr.substring(0, maxStr.length() - minStr.length()) + targetStr;
        } else {
            // 如果需要进位,分两种情况,一是原两字符串长度相同,这种情况加1即可
            // 二是原长字符串个位需要加1
            if (maxStr.length() == minStr.length()) {
                return '1' + targetStr;
            } else {
                return addStr(maxStr.substring(0, maxStr.length() - minStr.length()), "1") + targetStr;
            }
        }
    }


    public static void main(String[] args) {
        isDebug = false;
        String testStr11 = "1234";
        String testStr12 = "321";
        String testStr21 = "1234";
        String testStr22 = "4321";
        String testStr31 = "4321";
        String testStr32 = "1";

        String testStr51 = "789";
        String testStr52 = "9876";
        String testStr61 = "789";
        String testStr62 = "2876";

        String testStr81 = "345678967890987";
        String testStr82 = "56789045677654321";

        String testStr91 = "9999999999999999999999999999999";
        String testStr92 = "9999999999999999999999999999999";
        String testStr93 = "99999999999999999999999999999";
        String testStr94 = "9999999999999999999999999999999";

        print(testStr11, testStr12, "测试不需要进位1");
        print(testStr21, testStr22, "测试不需要进位2");
        print(testStr31, testStr32, "测试不需要进位3");

        print(testStr51, testStr52, "测试需要进位1");
        print(testStr61, testStr62, "测试需要进位2");

        print(testStr81, testStr82, "极端测试1");
        /// print(testStr91, testStr92, "极端测试2");
        System.out.println(addStr(testStr91, testStr92));
        System.out.println(addStr(testStr93, testStr94));

        System.out.println("".substring(0, 0));
    }

    private static void print(String str1, String str2, String testMsg) {
        System.out.println(testMsg);
        String expect = String.valueOf(Long.parseLong(str1) + Long.parseLong(str2));
        String actual = addStr(str1, str2);
        System.out.println(str1 + " + " + str2 + " 期望为" + expect);
        System.out.println(str1 + " + " + str2 + " 实际为" + actual);
        System.out.println("=========================");
        if (!expect.equals(actual)) {
            throw new RuntimeException("测试失败,请检查");
        }
    }
}

2、阿里一面(20210124)

1、概述

我也没想着简历居然能过,速度极快。24号晚上10点56点在boss直聘发起沟通,23点15就内推简历了。25号16点16确定面试时间,约的晚上7点半,虽然后面面试官迟到了半个小时,但总体来说,还是不错的,学到了很多。

2、面试体验

整个面试过程持续为65分钟,先是电话沟通,后面转为微信电话,原因是后者比较清晰,哈哈哈。

先是问我的一些个人经历、项目经历、绩效,然后画风一转就直接问技术了,看得出阿里的每个人都不简单,当时我都有点蒙了。

面试结果是挂了,原因有三(我的个人感觉),大致如下

  • 项目马马虎虎,没有涉及的大数据量、高并发,虽然业务比较复杂,讲不出什么特点。
  • 技术勉勉强强,有些技术还是不够扎实,技术点不够深,广度还欠了点。
  • 我拒绝了算法的笔试,哈哈哈,我后悔了,以后不敢了。

3、面试内容

  • 简单介绍一下自己

  • 问项目,有什么难点,如何解决

  • 问我的绩效,不知道为什么

  • IoC原理、Spring如何解决循环依赖 - 过

  • BeanFactory和FactoryBean的区别 - 过

  • Spring Boot的特点、Stater的特点 - 过

  • InnoDB的特性 - 过

  • 讲下事务、MVCC的原理机制 - (帮助链接

  • 讲下组合索引 (帮助链接

  • 为什么用B+树,为什么不用B树(帮助链接

  • mysql的几种日志的区别、mysql主从如何同步(帮助链接

  • Reids吞吐量为什么高

  • Redis集群、Redis主从

  • 讲下synchronized的原理、锁优化、锁升级

  • 消息队列,重点rocketmq

  • SPI(帮助链接帮助链接

  • bio中epool lpooo

4、笔试题

package real;
/// 1.将head链表以m为组反转链表(不足m则不反转):
/// 例子:假设m=3. 链表 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 ->8
/// output: 3 -> 2 -> 1 ->6 ->5 ->4 -> 7 ->8

/**
 * @author HK
 * @date 2021-01-28 14:01
 */
public class Main2 {

    static class Node {
        String value;
        Node next;

        public Node(String value, Node next) {
            this.value = value;
            this.next = next;
        }
    }

    /**
     * 将字符串数组转为Node节点链表
     */
    private static Node arrToNode(String[] str) {
        if (str == null) {
            throw new RuntimeException("入参不能为空");
        }
        Node head;
        if (str.length > 0) {
            head = new Node(str[0], null);
        } else {
            return null;
        }
        Node cur = head;
        for (int i = 1; i < str.length; i++) {
            cur.next = new Node(str[i], null);
            cur = cur.next;
        }
        return head;
    }

    /**
     * 打印节点信息
     */
    private static void printNode(Node node) {
        while (node != null) {
            System.out.print(node.value + " ");
            node = node.next;
        }
        System.out.println();
    }

    private static void printNode(Node head, String msg) {
        System.out.print(msg + ":  ");
        printNode(head);
    }

    /**
     * 假设节点为 1 -> 2 -> 3 -> 4,应该返回 4 -> 3 -> 2-> 1
     * 步骤是先把 1 和 2 反转(反转后为 2 -> 1 -> 3 -> 4),然后把 2 -> 1 当作一个整体,将 3 放回 2 后面
     * 以此类推
     */
    private static Node reverseNode(Node head) {
        Node newHead = head;
        Node cur = head;
        while (cur.next != null) {
            // 第一次为 1 | 第二次为 1
            Node pre = cur;
            // 第一次 为 2 | 第二次 3
            Node next = cur.next;
            // 使得 1 -> 3 | 使得 1 -> 4
            pre.next = next.next;
            // 使得 2 -> 1 | 使得 3 -> 2
            next.next = newHead;
            // 将当前要处理node变为1
            cur = pre;
            // 保存新的头指针
            newHead = next;
            /// printNode(newHead);
        }
        return newHead;
    }


    /**
     * 以n为一组,反转数组
     * 例子:假设m=3. 链表 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 ->8
     * output: 3 -> 2 -> 1 ->6 ->5 ->4 -> 7 ->8
     * 思路如下:
     * 1. 先读取 n 个,形成一个新链表,如 1 -> 2 -> 3 -> null
     * 2. 然后反转成为 3 -> 2 -> 1 -> null。保存头节点和尾节点信息
     * 3. 进行下一轮
     *
     * @param head      待反转头节点
     * @param groupSize 一组大小
     * @return 反转后的数组
     */
    private static Node reverseNode(Node head, int groupSize) {
        Node node = head;
        printNode(head, "输入反转链表(分组为" + groupSize + ")");
        Node rootHead = null, rootTail = null;
        while (node != null) {
            int dealCount = groupSize - 1;
            Node newTail = new Node(node.value, null);
            Node newHead = newTail;
            while (dealCount > 0 && node.next != null) {
                dealCount--;
                node = node.next;
                newTail.next = new Node(node.value, null);
                newTail = newTail.next;
            }
            node = node.next;
            newTail = newHead;
            // 是否满足一个分组,满足则反序,否则不作处理
            if (dealCount == 0) {
                newHead = reverseNode(newHead);
            }
            /// printNode(newHead, "新链表");
            /// printNode(newHead, "根节点");
            /// printNode(newTail, "尾节点");
            if (rootHead == null) {
                rootHead = newHead;
            } else {
                rootTail.next = newHead;
            }
            rootTail = newTail;
            /// printNode(rootHead, "最终链表");
        }
        return rootHead;
    }

    public static void main(String[] args) {
        String[] strArr1 = new String[]{"1", "2", "3", "4", "5", "6", "7", "8"};
        String[] strArr2 = new String[]{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"};
        String[] strArr3 = new String[]{};

        System.out.println("单纯的测试反转算法");
        printNode(arrToNode(strArr1));
        printNode(reverseNode(arrToNode(strArr1)));
        System.out.println("===============");

        // 官方给定例子
        printNode(reverseNode(arrToNode(strArr1), 3), "成功反转后输出");
        System.out.println("===============");
        printNode(reverseNode(arrToNode(strArr2), 3), "成功反转后输出");
        System.out.println("===============");
        printNode(reverseNode(arrToNode(strArr3), 3), "成功反转后输出");
        System.out.println("===============");

        ///  printNode(reverseNode(arrToNode(null), 3), "成功反转后输出");

        // 补充例子
        printNode(reverseNode(arrToNode(strArr1), 4), "成功反转后输出");
        System.out.println("===============");
        printNode(reverseNode(arrToNode(strArr2), 4), "成功反转后输出");
        System.out.println("===============");
        printNode(reverseNode(arrToNode(strArr3), 4), "成功反转后输出");

        // 两种极端情况
        printNode(reverseNode(arrToNode(strArr1), 10000000), "成功反转后输出");
        System.out.println("===============");
        printNode(reverseNode(arrToNode(strArr2), -111111), "成功反转后输出");
        System.out.println("===============");
    }
}

总结

我本无意炫耀,但这就是精品,未完待续。

总访问次数: 15次, 一般般帅 创建于 2020-05-24, 最后更新于 2024-05-10

进大厂! 欢迎关注微信公众号,第一时间掌握最新动态!