糖心vlog新闻资讯
糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧
发布时间:2024-12-16
  |  
阅读量:
字号:
A+ A- A

Exception 是程序正常运行过程中可以预料到的意外情况,应该被开发者捕获并且进行相应的处理。

Error 是指在正常情况下不太可能出现的情况,绝大部分的 Error 都会导致程序处于不正常、不可恢复的状态,也就是挂了。

所以不便也不需被开发者捕获,因为这个情况下你捕获了也无济于事。

Exception和Error都是继承了Throwable类,在Java代码中只有继承了Throwable类的实例才可以被throw或者被catch。

随便我再提一提异常处理的注意点,之前写过文章总结过:

尽量不要捕获类似Exception这样通用的异常,而应该捕获特定的异常。

软件工程是一门协作的艺术,在日常的开发中我们有义务使自己的代码能更直观、清晰的表达出我们想要表达的信息。

但是如果你什么异常都用了Exception,那别的开发同事就不能一眼得知这段代码实际想要捕获的异常,并且这样的代码也会捕获到可能你希望它抛出而不希望捕获的异常。

不要"吞"了异常

如果我们捕获了异常,不把异常抛出,或者没有写到日志里,那会出现什么情况?线上除了 bug 莫名其妙的没有任何的信息,你都不知道哪里出错以及出错的原因。

这可能会让一个简单的bug变得难以诊断,而且有些同学比较喜欢用 catch 之后用e.printStackTrace(),在我们产品中通常不推荐用这种方法,一般情况下这样是没有问题的但是这个方法输出的是个标准错误流。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图1)

比如是在分布式系统中,发生异常但是找不到stacktrace。

所以最好是输入到日志里,我们产品可以自定义一定的格式,将详细的信息输入到日志系统中,适合清晰高效的排查错误。

不要延迟处理异常

比如你有个方法,参数是个 name,函数内部调了别的好几个方法,其实你的name传的是 null 值,但是你没有在进入这个方法或者这个方法一开始就处理这个情况,而是在你调了别的好几个方法然后爆出这个空指针。

这样的话明明你的出错堆栈信息只需要抛出一点点信息就能定位到这个错误所在的地方,进过了好多方法之后可能就是一坨堆栈信息。

只在需要try-catch的地方try-catch,try-catch的范围能小则小

只要必要的代码段使用try-catch,不要不分青红皂白try住一坨代码,因为try-catch中的代码会影响JVM对代码的优化,例如重排序。

不要通过异常来控制程序流程

一些可以用if/else的条件语句来判断例如null值等,就不要用异常,异常肯定是比一些条件语句低效的,有 CPU 分支预测的优化等。

而且每实例化一个Exception都会对栈进行快照,相对而言这是一个比较重的操作,如果数量过多开销就不能被忽略了。

不要在finally代码块中处理返回值或者直接return

在finally中return或者处理返回值会让发生很诡异的事情,比如覆盖了 try 中的return,或者屏蔽的异常。

深拷贝:完全拷贝一个对象,包括基本类型和引用类型,堆内的引用对象也会复制一份。

浅拷贝:仅拷贝基本类型和引用,堆内的引用对象和被拷贝的对象共享。

所以假如拷贝的对象成员间有一个 list,深拷贝之后堆内有 2 个 list,之间不会影响,而浅拷贝的话堆内还是只有一个 list。

比如现在有个 teacher 对象,然后成员里面有一个 student 列表。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图2)

因此深拷贝是安全的,浅拷贝的话如果有引用对象则原先和拷贝对象修改引用对象的值会相互影响。

面向对象编程(Object Oriented Programming,OOP)是一种编程范式或者说编程风格。

把类或对象作为基本单元来组织代码,并且运用提炼出的:封装、继承和多态来作为代码设计指导。

面向过程编程是以过程作为基本单元来组织代码的,过程其实就是动作,对应到代码中来就是函数,面向过程中函数和数据是分离的,数据其实就是成员变量。

而面向对象编程的类中数据和动作是在一起的,这也是两者的一个显著的区别。


重载:指的是方法名相同,参数类型或者顺序或个数不同,这里要注意和返回值没有关系,方法的签名是名字和参数列表,不包括返回值。

重写:指的是子类重写父类的方法,方法名和参数列表都相同,也就是方法签名是一致的。重写的子类逻辑抛出的异常和父类一样或者是其父类异常的子类,并且方法的访问权限不得低于父类。

简单的理解为儿子不要超过爸爸,要尊老爱幼。

内部类顾名思义就是定义在一个类的内部的类,按位置分:在成员变量的位置定义,则是成员内部类,在方法内定义,则是局部内部类。

如果用 static 修饰则为静态内部类,还有匿名内部类。

一般而言只会用成员内部类、静态内部类和匿名内部类。

成员内部类可以使用外部类的所有成员变量以及方法,包括 private 的。

静态内部类只能使用外部类的静态成员变量以及方法。

匿名类常用来作为回调,使用的时候再实现具体逻辑来执行回调。

实际上内部类是一个编译层面的概念,像一个语法糖一样,经过编译器之后其实内部类会提升为外部顶级类,和外部类没有任何区别,所以在 JVM 中是没有内部类的概念的

一般情况下非静态内部类用在内部类和其他类无任何关联,专属于这个外部类使用,并且也便于调用外部类的成员变量和方法,比较方便。

静态外部类其实就等于一个顶级类,可以独立于外部类使用,所以更多的只是表明类结构和命名空间。

这种问题一般大致提一下,然后等着面试官深挖。

常用的集合有 List、Set、Map、Queue 等。

List 常见实现类有 ArrayList 和 LinkedList。

  • ArrayList 基于动态数组实现,支持下标随机访问,对删除不友好。
  • LinkedList 基于双向链表实现,不支持随机访问,只能顺序遍历,但是支持O(1)插入和删除元素。

Set 常见实现类有:HashSet、TreeSet、LinkedHashSet。

  • HashSet 其实就是 HashMap 包了层马甲,支持 O(1)查询,无序。
  • TreeSet 基于红黑树实现,支持范围查询,不过基于红黑树的查找时间复杂度是O(lgn),有序。
  • LinkedHashSet,比 HashSet 多了个双向链表,通过链表保证有序。

Map 常见实现类有:HashMap、TreeMap、LinkedHashMap

  • HashMap:基于哈希表实现,支持 O(1) 查询,无序。
  • TreeMap:基于红黑树实现,O(lgn)查询,有序。
  • LinkedHashMap:同样也是多了双向链表,支持有序,可以很好的支持 lru 的实现。

设置有序,并且重写LinkedHashMap中的 removeEldestEntry 方法,即可实现 lru。

这里有一点要提一下,如果你对某个东西比较熟悉就要在合适的地方抛出来。比如通过 LinkedHashMap 你还能延伸到 lru ,这表明你对 LinkedHashMap 有研究并且也知晓 lru,面试官自己可能都不清楚,会觉得你有点东西。

而且面试官基本会追问 lru 然后接着延伸,比如延伸到改进的 lru ,mysql 缓存中的 lru 等等,这就是通过你的引导把问题领域迁移到你自身熟悉的地方,这岂不美哉?如果你不熟悉,那少 bb。

Queue 常见的实现类有:LinkedList、PriorityQueue。

PriorityQueue:优先队列,是基于堆实现的,底层其实就是数组。

基本上回答不了这么全,稍微讲几个可能就被打断,然后深挖了,届时只能见招拆招。

ThreadLocal 本质是通过本地化资源来避免共享,也就是每个线程都有自己的本地私有化变量,这样每个线程访问自己属性即可,避免了多线程竞争导致的锁等消耗。

具体关系如下图所示:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图3)

ThreadLocalMap 是采用线性探测的方式来解决 hash 冲突,所以要注意 ThreadLocal 的数量,因为这种冲突解决方式比较低效。

ThreadLocal 在 Entry 中作为 key 是弱引用,所以当外部对 ThreadLocal 的强引用消失之后,只剩下弱引用的 ThreadLocal 会被 GC 清除,这时候 Entry 中的 value 还在,但是已经访问不到了,所以称之为内存泄漏。

不过当调用 get 和 set 方法时,如果直接 hash 没中,开始线性探测,那么碰到 key 为 null 的节点才会清理掉。

当然更好的方式是显示的在用完之后调用 remove,这样就能及时清理。

既然弱引用会导致内存泄漏,那为什么还要弱引用?

首先如果 key 不用弱引用,那么当外部对 ThreadLocal 的强引用消失之后,由于 ThreadLocalMap 是这个线程的成员之一,所以这个线程还在,那么 ThreadLocalMap 就在,而 ThreadLocalMap 在,那么 Entry 肯定在,而 Entry 在那么强引用的 key 和 value 就肯定在。

所以如果 key 不用弱引用,那么 key 都无法被 GC 。

所以 key 用弱引用那么至少 key 这点内存是可以被省掉的,并且线性探测还能清一些 Entry。

其实发生内存泄漏的根本不在于 key 是弱引用,因为他们都属于一个线程的属性,所以线程活着它们就不能被 GC,这一条引用链是无法更改的。

然后现在都是用线程池,所以线程有可能长时间存活,因此就会逐渐堆积,导致内存满了。

所以这点需要明确。

与之相关的还有个 InheritableThreadLocal

这玩意可以理解为就是可以把父线程的 threadlocal 传递给子线程,所以如果要这样传递就用 InheritableThreadLocal ,不要用 threadlocal。

原理其实很简单,在 Thread 中已经包含了这个成员:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图4)

在父线程创建子线程的时候,子线程的构造函数可以得到父线程,然后判断下父线程的 InheritableThreadLocal 是否有值,如果有的话就拷过来。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图5)

这里要注意,只会在线程创建的时会拷贝 InheritableThreadLocal 的值,之后父线程如何更改,子线程都不会首其影响。

至此有关 ThreadLocal 的知识点就差不多了。

我们的程序和硬件之间隔了个操作系统,而为了安全考虑,Linux 系统分了:用户态和内核态。

在这个前提下,我们再明确程序要从磁盘(网卡)读数据的两个步骤:

  1. 数据从存储设备拷贝到内核缓存
  2. 数据再从内核缓存拷贝到用户空间
糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图6)

好了,现在咱们可以看看这几个概念了。

  • 同步I/O:指的是线程需要等待 2 执行完毕。
  • 异步I/O:指的是线程不需要等待 2 执行。
  • 阻塞I/O:指的是步骤 1 会阻塞,即线程需要阻塞等待 1 执行完毕。
  • 非阻塞I/O:指的是步骤 1 不会被阻塞,不需要阻塞等待。

所以平时还会有同步阻塞I/O,或者啥同步非阻塞I/O,就是步骤 1 和 2 的组合罢了。

我们再来理解一下,毕竟咱们这面霸系列不是背诵,是理解。

同步和异步指的是:是否需要等待方法的调用执行完毕。

这两个概念主要注重的是调用方式,同步和异步调用编码方式是不同的,同步其实就是一条道写下来,异步则是需要回调、事件等方式来实现后面的逻辑。

阻塞和非阻塞:一般用在底层系统调用身上,阻塞指的是线程未满足条件会被阻塞,进入 sleep 状态,即时间片还未到就让出 CPU,非阻塞则是计算未满足条件也直接返回。

所以阻塞是真的被阻塞住了,是在等待数据,是需要让出时间片的。

而同步的线程其实还是有时间片的,所以同步一般有个超时时间,计算超时之后就会返回继续执行后面的代码。

BIO 指的是同步阻塞I/O,相信看了 28 题之后对这个同步阻塞很清晰了,就是等着。

在这种模型下只能是来一个连接用一个线程,连接多并发大的话服务器顶不住这么多线程的。

NIO 指的是同步非阻塞I/O,我们熟知的 IO 多路复用就是NIO,适合用在连接多、每次传输较为短的场景。

AIO 指的是异步I/O,调用了之后就不管了,数据来了自动会执行回调方法。

异步可以有效的减少线程的等待,减少了用户线程拷贝数据的那段等待,效率更高。

JDK8 较为重要和平日里经常被问的特性如下:

  • 用元空间替代了永久代。
  • 引入了 Lambda 表达式。
  • 引入了日期类、接口默认方法、静态方法。
  • 新增 Stream 流式接口

然后相信你们对 HashMap 和 ConcurrentHashMap 有一定的准备,所以抛出来

  • 修改了 HashMap 和 ConcurrentHashMap 的实现(等着八股文之问)
  • 新增了 CompletableFuture 、StampedLock 等并发实现类。

像一些中间件异步化代码都用了 CompletableFuture 来实现,所以还是得做一些了解的,如果不熟悉这条就不用提了。

Semaphore、CyclicBarrier、CountDownLatch 三连。

当然 JUC 下面还有挺多,反正列几个说说就行,面试的时候切忌不要一股脑儿的把知道的都扔出来,这叫留白

这玩意叫信号量,广泛应用于各种操作系统中,相对于平日只允许一个线程访问临界区的 lock 和 synchronized 来说,信号量允许多线程同时访问一个临界区

原理就简单的理解为初始化一个数,如果来了一个线程则把数减一,如果减一之后数的值小于 0 则阻塞当前线程,移入一个阻塞队列中,否则允许执行。

当一个线程执行完毕之后将数加一,并唤醒阻塞队列中的一个等待线程。

实际是内部有个继承自 AQS 的 Sync 类,通过依托 AQS 的封装来实现功能。

主要用于流量的控制,比如停车场只允许停一定数量的车位。

简单示例如下:

 int count;
    final Semaphore semaphore   = new Semaphore(1); // 初始化信号量
    // 用信号量保证互斥    
    void addOne() {
      try {
          semaphore.acquire();   //对应down,计数减一
          count+=1;
        } catch (InterruptedException e) {
          e.printStackTrace();
        } finally {
          semaphore.release();  //对应up,计数加一
        }
    }

从名字分析,这是一个可循环的屏障。

屏障的意思是:让一组线程都运行到同一个屏障点之后,线程会阻塞等待所有线程都达到这个屏障点,然后所有线程才得以继续执行。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图7)

来看一下用法:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图8)

结果如下:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图9)

它实际上是基于 ReentrantLock 和 Condition 的封装来实现这一功能的。

原理我先口述一下,因为面试官很有可能会问原理

首先设置了达到屏障的线程数量,当线程调用 await 的时候计数器会减一,如果计数器减一不等于 0 的时候,线程会调用 condition.await 进行阻塞等待。

如果计数器减一的值等于0,说明最后一个线程也到达了屏障,于是如果有 barrierCommand 就执行 barrierCommand ,然后调用 condition.signalAll 唤醒之前等待的线程,并且重置计数器,然后开启下一代。

源码我就不贴了,建议自己看下,不难的,算上一大推注释都不到 500 行,核心方法就 60 几行。

至于循环的话,来看一下这个代码就理解了:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图10)

当规定数量的线程到达屏障之后会把计数重置回去,并且开启了下一代,所以 CyclicBarrier 是可以循环使用的。

这个锁其实和 CyclicBarrier 有点类似,都是等待一个节点的到达,但是还是不太一样的。

CyclicBarrier 是各个线程等待阻塞所有线程都达到一个节点之后,所有线程继续执行。

CountDownLatch 是一个线程阻塞着等待其他线程到达一个节点之后才能继续执行,这个过程中其他线程是不会阻塞的

示例如下:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图11)

结果如下:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图12)

实现原理:内部又一个继承自 AQS 的 Sync 类,核心其实就是围绕一个整数 state。

初始化 state 的值,当调用一次 countDown 会把 state 的值减一,当 state 的值减到 0 的时候就会唤醒之前调用 await 等待的线程。

主要是依靠 AQS 封装的好,所以代码很少,原理也很清晰简单。

既然30题提到了这个,之前也专门写过文章分析,那刚好拿来讲讲。

可以认为是读写锁的“改进”版本。读写锁读写是互斥的,而 StampedLock 搞了个悲观读和乐观读,悲观读和写是互斥的,乐观读则不会。

搞个官方示例看下就很清晰了:

 class Point {
   private double x, y;
   private final StampedLock sl = new StampedLock();

   void move(double deltaX, double deltaY) { // an exclusively locked method
     long stamp = sl.writeLock();  //获取写锁
     try {
       x += deltaX;
       y += deltaY;
     } finally {
       sl.unlockWrite(stamp); //释放写锁
     }
   }

   double distanceFromOrigin() { // A read-only method
     long stamp = sl.tryOptimisticRead(); //乐观读
     double currentX = x, currentY = y;
     if (!sl.validate(stamp)) { //判断共享变量是否已经被其他线程写过
        stamp = sl.readLock();  //如果被写过则升级为悲观读锁
        try {
          currentX = x;
          currentY = y;
        } finally {
           sl.unlockRead(stamp); //释放悲观读锁
        }
     }
     return Math.sqrt(currentX * currentX + currentY * currentY);
   }

   void moveIfAtOrigin(double newX, double newY) { // upgrade
     // Could instead start with optimistic, not read mode
     long stamp = sl.readLock(); //获取读锁
     try {
       while (x == 0.0 && y == 0.0) {

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图13)

long ws = sl.tryConvertToWriteLock(stamp); //升级为写锁 if (ws != 0L) { stamp = ws; x = newX; y = newY; break;

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图14)

} else { sl.unlockRead(stamp); stamp = sl.writeLock(); } } } finally { sl.unlock(stamp); } } }

乐观锁就是获取判断一下,如果被修改了那么就升级为悲观锁。

但是 Semaphore 是不可重入锁,而且也不支持 condition 。并且如果线程使用writeLock() 或者readLock() 获得锁之后,线程还没执行完就被 interrupt() 的话,会导致CPU飙升,需要用 readLockInterruptibly 或者 writeLockInterruptibly

阻塞队列主要用来阻塞队列的插入和获取操作,当队列满了的时候阻塞队列的插入操作,直到队列有空位。当队列为空的时候阻塞队列的获取操作,直到队列有值。

常用在实现生产者和消费者场景,在笔试题中比较常见。

常见的有 ArrayBlockingQueue 和 LinkedBlockingQueue,分别是基于数组和链表的有界阻塞队列。

两者原理都是基于 ReentrantLock 和 Condition 。

都是有界阻塞队列两者有什么区别?

ArrayBlockingQueue 基于数组,内部实现只用了一把锁,可以指定公平或者非公平锁。

LinkedBlockingQueue 基于链表,内部实现用了两把锁,take 一把、put 一把,所以入队和出队这两个操作是可以并行的,从这里看并发度应该比 ArrayBlockingQueue 高。

还有 PriorityBlockingQueue 和 DelayQueue,分别是支持优先级的无界阻塞队列和支持延时获取的无界阻塞队列,如果你看过 DelayQueue 实现就会发现内部用的是 PriorityQueue。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图15)

还有 SynchronousQueue、 LinkedBlockingDeque 和 LinkedTransferQueue。

SynchronousQueue 前面线程池分析有提到过,它是不占空间的,入队比如等待一个出队,也就是生产者必须等待消费者拿货,无法把先把货存在队列。

LinkedBlockingDeque 是双端阻塞无界队列,就是队列的头尾都能操作,头尾都能插入和移除。

LinkedTransferQueue,相对于其他阻塞队列从名字来看它有 Transfer 功能,其实也不是什么神奇功能,一般阻塞队列都是将元素入队,然后消费者从队列中获取元素。

而 LinkedTransferQueue 的 transfer 是元素入队的时候看看是否已经有消费者在等了,如果有在等了直接给消费者即可,所以就是这里少了一层,没有锁操作。

原子类是 JUC 封装的通过无锁的方式实现的一系列线程安全的原子操作类。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图16)

上面截图的原子类主要分为五大类,我画个脑图汇总一下:

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图17)

原子类的核心原理就是基于 CAS(Compare And Swap)。

CAS 简单的理解为:给予一个共享变量的内存地址,然后内存中应该的值(预期值)和新值,然后通过一条 CPU 指令来比较此内存地址上的值是否等于预期值,如果是则替换内存地址上的值为新值,如果不是则不予替换且换回。

也就是说硬件层面支持一条指令来实现这么几个操作,一条指令是不会被打断的,所以保证了原子性。

可以简单的理解为通过基本类型原子类 AtomicBoolean、AtomicInteger 和 AtomicLong 就可线程安全地、原子地更新这几个基本类型。

AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,简单的理解为可以原子化地更新数组内的每个元素,几个的差别无非就是数组里面存储的数据是什么类型。

AtomicReference、AtomicStampedReference 和 AtomicMarkableReference,就是对象引用的原子化更新。

差别在于 AtomicStampedReference 和 AtomicMarkableReference 可以避免 CAS 的 ABA 问题。

AtomicStampedReference 是通过版本号 stamp 来避免, AtomicMarkableReference 是通过一个布尔值 mark 来避免。

ABA 问题

因为 CAS 是将期望值和当时内存地址上的值进行对比,假设期望值是 1 ,地址上的值现在是 1,只不过中间被人改成了 2 ,然后又改回了1,所以此时你 CAS 操作去对比是可以替换的,你无法得知中间值是否改过,这种情况就叫 ABA 问题。

而解决 ABA 问题的做法就是用版本号,每次修改版本就+1,这样即使值是一样的但是版本不同,就能得知之前被改过了。

AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater,是通过反射,原子化的更新对象的属性,不过要求属性必须用 volatile 修饰来保证可见性,看下源码,很直观。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图18)

上述的都是更新数据,而 DoubleAccumulator、DoubleAdder、LongAccumulator 和 LongAdder 主要用来累加数据。

首先 AtomicLong 也能累加,而 LongAdder 是专业累加,也只能累加,并发度更高,它通过分多个 cells 来减少线程的竞争,提高了并发度。

你可以理解为如果拿 AtomicLong 是实现累加就是一本本子,然后 20 个人要让本子上累加计数。

而 LongAdder 分了 10 个本子,20个人可以分别拿这 10 个本子来计数(减少了竞争,提高了并发度),然后最后的结果再由 10个本子上的数相加即可。

xxxAccumulator 和 xxxAdder 两者的区别?

xxxAccumulator 的功能比 xxxAdder 丰富,可以自定义累加方法,也可以设置初始值,按照注释上的解释 xxxAdder 等价于 new xxxAccumulator((x, y) -> x + y, 0L}。

所以可以说 xxxAdder 是 xxxAccumulator 的一个特例。

Synchronized 和 ReentrantLock 都是可重入锁,ReentrantLock 需要手动解锁,而 Synchronized 不需要。

ReentrantLock 支持设置超时时间,可以避免死锁,比较灵活,并且支持公平锁,可中断,支持条件判断。

Synchronized 不支持超时,非公平,不可中断,不支持条件。

总的而言,一般情况下用 Synchronized 足矣,比较简单,而 ReentrantLock 比较灵活,支持的功能比较多,所以复杂的情况用 ReentrantLock 。

至于说 Synchronized 性能不如 ReentrantLock 的,那都是 N 多年前的事儿了。

Synchronized 的原理其实就是基于一个锁对象和锁对象相关联的一个 monitor 对象。

在偏向锁和轻量级锁的时候只需要利用 CAS 来操控锁对象头即可完成加解锁动作。

在升级为重量级锁之后还需要利用 monitor 对象,利用 CAS 和 mutex 来作为底层实现。

monitor 对象颞部会有等待队列和条件等待队列,未竞争到锁的线程存储到等待队列中,获得锁的线程调用 wait 后便存放在条件等待队列中,解锁和 notify 都会唤醒相应队列中的等待线程来争抢锁。

然后由于阻塞和唤醒依赖于底层的操作系统实现,系统调用存在用户态与内核态之间的切换,所以有较高的开销,因此称之为重量级锁

所以才会有偏向锁和轻量级锁的优化,并且引入自适应自旋机制,来提高锁的性能。

关于 Synchronized 其实我写过两篇文章,看完这两篇文章你可以跟 Synchronized 说,你看过 JVM 源码,毫不夸张,因为就是从源码级别上分析的。

而且指明了一个几乎网上都错了的观点和一个常见的认知错误。

总而言之,看完之后对 Synchronized 基本上超越很多人了。

Synchronized 深入JVM分析

Synchronized 升级到重量级锁之后就下不来了?你错了!

ReentrantLock 其实就是基于 AQS 实现的一个可重入锁,支持公平和非公平两种方式。

内部实现其实就是依靠一个 state 变量和两个等待队列:同步队列和等待队列。

利用 CAS 修改 state 来争抢锁。

争抢不到则入同步队列等待,同步队列是一个双向链表。

条件 condition 不满足时候则入等待队列等待,也是个双向链表。

是否是公平锁的区别在于:线程获取锁时是加入到同步队列尾部还是直接利用 CAS 争抢锁。

就是这么回事儿,理解起来应该不难,操心的我再画个图,嘿嘿。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图19)

AQS 的原理其实就是上面提到的,这里就不再赘述了。

如果面试官问你为什么需要 AQS ,就这样回答。

AQS 将一些操作封装起来,比如入队等基本方法,暴露出方法,便于其他相关 JUC 锁的使用。

比如 CountDownLatch、Semaphore 等等。

就是起到了一个抽象,封装的作用。

读写锁在 Java 中一般默认指的是 ReentrantReadWriteLockTXAPP.TV。

读写锁是有两把锁,分别是读锁和写锁。

除了读读操作不互斥之外,其他都互斥。

所以读很多写比较少的情况,用读写锁比较合适。

还有一点要注意,如果不是这种情况不要用读写锁,因为读写锁需要额外维护读锁的状态,所以如果读读操作不多还不如一般的锁

读写锁也是基于 AQS 实现的,再具体点的实现就是将 state分为了两部分,高16bit用于标识读状态、低16bit标识写状态。

就这样灵巧的通过一个 state 实现了两把锁,嘿嘿。

CAS 就是 compare and swap,即比较并交换。

举个例子,我们经常有累加需求,比较一个值是否等于 1,如果等于 1 我们将它替换成 2,如果等于 2 替换成 3。

这种比较在多线程的情况下就不安全,比如此时同时有两个线程执行到比较值是否等于 1,然后两个线程发现都等于 1。

然后两个线程都将它变成了 2,这样明明加了两次,值却等于 2。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图20)

这种情况其实加锁可以解决,但是加锁是比较消耗资源的。

因此硬件层面就给予支持,将这个比较和交换的动作封装成一个指令,这样就保证了原子性,不会判断值确实等于 1,但是替换的时候值以及不等于 1了。

这指令就是 CAS。

CAS 需要三个操作数,分别是旧的预期值(图中的1),变量内存地址(图中a的内存地址),新值(图中的2)。

指令是根据变量地址拿到值,比较是否和预期值相等,如果是的话则替换成新值,如果不是则不替换

其实 33 题已经提过这个了,包括 ABA 问题,之所以再写一下是可以从我提供的思路跟面试官说。

不要一上来就三个操作数

你把遇到的场景(上面说的累加),然后多线程不安全,然后用锁不好,然后硬件提供了这个指令。

按这样的思路说出来,如果我是面试官,我一听就会觉得,小伙子可以。

可以分为初始状态、可运行状态、终止状态和休眠状态四大类。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图21)

线程新建的时候就是初始状态,还未start。

可运行状态就是可以运行,可能正在运行,也可能正在等 CPU 时间片。

休眠状态分为三种,一种是等待锁的 blocked 状态,一种是等待条件的 waitting 状态,或者有时间限制的等待 timed_waitting 状态。

  • 等待条件的操作有:Object.wait、Thread.join、LockSupport.park()
  • 时间等待就是上面设置了timeout参数的方法,例如Object.wait(1000)。

终止状态就是线程结束执行了,可以是结束任务后的自动结束,也可以是产生了异常而结束。

JMM 即 Java Memory Model,Java 内存模型。

JMM 其实是一组规则,规定了一个线程的写操作何时会对另一个线程可见(JSR133)。

抽象的来看 JMM 会把内存分为本地内存和主存,每个线程都有自己的私有化的本地内存,然后还有个存储共享数据的主存。

由 JMM 来定义这两个内存之间的交互规则。

这里要注意本地内存只是一种抽象的说法,实际指代:寄存器、CPU 缓存等等。

总之 JMM 屏蔽了各大底层硬件的细节,是抽象出来的一套虚拟机层面的内存规范。

原子性

指的是一个操作不会被中断,要么这个操作执行完毕,要么不会执行,不会有执行一半的存在。

可见性

指的是一个线程对某个共享变量进行了修改,则其他线程能立刻获取到最新值。

有序性

指的是编译器或者处理器会将指令进行重排,这种操作会影响多线程的执行顺序导致错误。

这题我不太想写答案,内容比较死板,建议还是看《深入理解JVM虚拟机》吧,不过那本书里面没有详细说 ZGC。

而 ZGC 我倒是写了一篇,可以看看呐。

美团面试官问我:ZGC 的 Z 是什么意思?

一共有两种方式,分别是引用计数和可达性分析。

引用计数有循环依赖的问题,但是是可以解决的。

可达性分析则是从根引用(GCRoots) 开始进行引用链遍历扫描,如果可达则对象存活,如果不可达则对象已成为垃圾。

所谓的根引用包括全局变量、栈上引用、寄存器上的等。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图22)



常见的就是:复制、标记-清除、标记整理。

标记-清除算法应该是最符合我们人一开始处理垃圾的思路的算法。

例如我们想清除房间的垃圾,我们肯定是先定位(对应标记)哪些是垃圾,然后把这些垃圾之后扔了(对应清除),简单粗暴,剩下的不是垃圾的东西我也懒得理,不管了哈哈哈。

但是,这算法有个缺点:

  1. 空间碎片问题,这样会使得比较大的对象要申请比较多的连续空间的时候申请不到,明明你空间还很足的。然后导致又一次GC。

复制算法一般用于新生代,粗暴的复制算法就是把空间一分为二,然后将一边存活的对象复制到另一边,这样没有空间碎片问题,但是内存利用率太低了,只有 50%,所以 HotSpot 中是把一块空间分为 3 块,一块Eden,两块Survivor。

因为正常情况下新生代的大部分对象都是短命鬼,所以能活下来的不多,所以默认的空间划分比例是 8:1:1。

用法就是每次只使用Eden和一块Survivor,然后把活下来的对象都扔到另一块Survivor。再清理Eden和之前的那块Survivor。然后再把Eden和存放存活对象的那一块Survivor用来迎接新的对象。就等于每次回收了之后都会对调一下两个Survivor。

标记-整理算法的思路也是和标记-清除算法一样,先标记那些需要清除的对象,但是后续步骤不一样,它是整理,对就是像上面说的那些清除房间垃圾每次都会整理的人一样那么勤劳。

每次会移动所有存活的对象,且按照内存地址次序依次排列,也就是把活着的对象都像一端移动,然后将末端内存地址以后的内存全部回收。所以用了它也就没有空间碎片的问题了。


String 是 Java 中基础且重要的类,并且 String 也是 Immutable 类的典型实现,被声明为 final class,除了 hash 这个属性其它属性都声明为 final。

因为它的不可变性,所以例如拼接字符串时候会产生很多无用的中间对象,如果频繁的进行这样的操作对性能有所影响。

StringBuffer 就是为了解决大量拼接字符串时产生很多中间对象问题而提供的一个类,提供 append 和 add 方法,可以将字符串添加到已有序列的末尾或指定位置。

它的本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上了 synchronized。但是保证了线程安全是需要性能的代价的。

在很多情况下我们的字符串拼接操作不需要线程安全,这时候StringBuilder登场了,StringBuilder是JDK1.5发布的,它和StringBuffer 本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销

StringBuffer 和 StringBuilder 二者都继承了 AbstractStringBuilder ,底层都是利用可修改的 char 数组(JDK 9 以后是 byte 数组)。

所以如果我们有大量的字符串拼接,如果能预知大小的话最好在new StringBuffer 或者StringBuilder 的时候设置好 capacity,避免多次扩容的开销。

扩容要抛弃原有数组,还要进行数组拷贝创建新的数组。

这个问题估计应该都是来自《深入理解Java虚拟机》这本书的。

happens-before 就是定义的一些规则,在一些特定场景下,一些操作会先行发生于另一些操作。

A先行发生于B,其实含义就是 A 操作得到的结果在 B 操作开始时可以得到,重点不在于 A 执行的时间比 B 早,而是 A 的结果是可以在 B 开始时候被 B 读取的。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图23)

这是 JVM 规定的有序性,你也可以认为写 JVM 的程序员需要按照这样的规则来实现 JVM。

如操作符合以下的规则就会按照下面的定义动作先行发生。

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  • 传递性规则:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

这里指的就是 Syncronized 在身为重量级锁时候的自旋。

具体指的是在重量级锁时,一个线程如果竞争锁失败会进行自旋操作,说白了就是执行一些无意义的执行,空转 CPU 等着锁的释放。

因为一些情况下可能线程刚被阻塞,锁就被释放了,这样开销就比较大,所以自旋在一定程度上是有优化的。

形象一点就像怠速停车和熄火的区别,如果等待时候很长(长时候都拿不到锁),那肯定熄火划算(阻塞)。

如果一会儿就要出发(拿到锁),那怠速停车(自旋)比较划算。

不过因为这个自旋次数不好判断,所以引入自适应自旋

说白了就是结合经验值来看,如果上次自旋一会儿就拿到锁,那这次多自旋几次,如果上次自旋很久都拿不到,这次就少自旋。

这就叫锁的自适应自旋。

Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图24)

程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要管理。

而堆和方法区是线程共享的,所以垃圾回收器会关注这两个地方。

堆只要存放的就是平时 new 的对象。

方法区存放的就是加载的类型信息、即时编译(JIT)后的代码等。

为了提高程序执行的效率,CPU或者编译器就将执行命令重排序。

原因是因为内存访问的速度比 CPU 运行速度慢很多,因此需要编排一下执行的顺序,防止因为访问内存的比较慢的指令而使得 CPU 闲置着。


总之为了提高效率就会有指令重排的情况,导致指令乱序执行的情况发生,不过会保证结果肯定是与单线程执行结果一致的,这叫 as-if-serial

不过多线程就无法保证了,在 Java 中的 volatile 关键字可以禁止修饰变量前后的指令重排。

不可以

你可能看到一些答案说可以保证可见性,那不是我们常说的可见性

一般而言我们指的可见性是一个线程修改了共享变量,另一个线程可以立马得知更改,得到最新修改后的值。

而 final 并不能保证这种情况的发生,volatile 才可以。

而有些答案提到的 final 可以保证可见性,其实指的是 final 修饰的字段在构造方法初始化完成,并且期间没有把 this 传递出去,那么当构造器执行完毕之后,其他线程就能看见 final 字段的值。

如果不用 final 修饰的话,那么有可能在构造函数里面对字段的写操作被排序到外部,这样别的线程就拿不到写操作之后的值。

来看个代码就比较清晰了。

public class YesFinalTest {
   final int a; 
   int b;
   static YesFinalTest testObj;

   public void YesFinalTest () { //对字段赋值
       a = 1;
       b = 2;
   }

   public static void newTestObj () {  // 此时线程 A 调用这个方法
       testObj = new YesFinalTest ();
   }

   public static void getTestObj () {  // 此时线程 B 执行这个方法
       YesFinalTest object = obj; 
       int a = object.a; //这里读到的肯定是 1
       int b = object.b; //这里读到的可能是 2
   }
}

对于 final 域,编译器和处理器要遵守两个重排序规则(参考自infoq程晓明):

  1. 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。初次读一个包含
  2. final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

所以这才是 final 的可见性,这种可见性和我们在并发中常说的可见性不是一个概念!

所以 final 无法保证可见性

要注意锁的粒度,不能粗暴的直接在方法外围定义锁,锁的代码块越小越好,像双检锁就是典型的优化。

不同场景定义不同的锁,不能粗暴的一把锁搞定,例如在读多写少的场景可以使用读写锁、写时复制等糖心。


小伙伴们有兴趣想了解内容和更多相关学习资料的请点赞收藏+评论转发+关注我,后面会有很多干货。我有一些面试题、架构、设计类资料可以说是程序员面试必备!所有资料都整理到网盘了,需要的话欢迎下载!私信我回复【111】即可免费获取

糖心vlogTXAPP.TV:这30个我精选的含答案的面试题,硬不硬你说吧(图25)












































































原创 是Yes呀

https://mp.weixin.qq.com/s/1SMQTkR88lyzazEQOdW34g


TXAPP.TV 糖心