1. AQS 知道吗?讲讲你的理解

顺着这样的思路来讲:Why - What - How。为什么会有这个东西?这个东西是什么?怎么做到的?

任何技术的产生第一件事就是思考Why?这个AQS的产生是为什么,大家平常用过synchronized,可能觉得已经有锁了,还需要AQS吗?

Why: synchronized 只解决了有和没有的问题,但是锁的场景和多样性方面还很欠缺,例如: 带超时时间的获取锁、获取锁非阻塞(尝试获取锁)、带等待条件的请求锁。其实这些你通过synchronized 加手动封装也能实现,但是需要些功力而且还容易出错,所以Doug Lea就写了功能更加丰富的AQS以及一些一系列多线程组件,方便大家按需扩展。AQS 是JDK1.5 引入的,那个时候synchronized 还没做优化,没有偏向锁和轻量级锁,所以AQS 的 CAS(Reentrantlock) 自旋就很有必要了,毕竟有时间竞争并不激烈。

What: AQS是AbstractQueuedSynchronizer 的缩写,抽象队列同步器,我们很多同步工具ReentrantLock、Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier 都是基于AQS 实现的。

How: AQS实现机制是什么呢?大家可以先不往下看,如果让你来实现一个多线程控制访问共享资源的工具,你会如何写?考虑这么几个问题:

  1. 当有多个线程竞争的时候,运行线程排队等待获取资源,如何做?

  2. 当某个线程使用完资源,如何通知正在排队等待的资源?

  3. synchronized获取锁是阻塞的,也就是线程获取锁的时候一定会进入等待,但是如果希望实现一个线程过来访问,发现已经有其他线程持有锁了,直接返回,不希望产生锁竞争,怎么实现?

AQS 基本的原理是它提供了一套共享资源的访问的规范,通过CLH(一个双向链表)的方式把线程等待管理起来。

高频Java面试题解-第二趴_客户端CLH

它底层采用的是状态标志位(state变量)+FIFO队列的方式来记录获取锁、释放锁、竞争锁等一系列锁操作;

对于AQS而言,其中的state变量可以看做是锁,队列采用的是先进先出的双向链表,state共享状态变量表示锁状态,内部使用CAS对state进行原子操作修改来完成锁状态变更(锁的持有和释放)。

因为AQS过于重要,我后面会专门写篇Reentantlock 来深入介绍AQS。

    1. volatile 用过吗?

      参考如下文字:

一个volatile跟面试官扯了半个小时

    1. 实现一个生产者消费者模式

这个实现方式有很多种,通过synchronized的wait、notify可以,通过Reentrantlock 的Condition也可以,当然还有别的方式,另外单独讲。

下面介绍第一种:

基本原理就是通过锁 + 等待/唤醒实现生产和消费

高频Java面试题解-第二趴_老年代_02

 

  1. Java内存模型清楚吗?

    JMM 全称 Java Memory Model, 是 Java 中非常重要的一个概念,是Java 并发编程的核心和基础。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果。

    所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存的拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

    深入的Java内存模型介绍在这篇文章里:一个volatile跟面试官扯了半个小时

  2. 遇到过线上性能问题吗?怎么排查原因的?

    这个因人而异,我给出几种类似常见的,第一种,JVM Full GC的,第二种,CPU 100%的,第三种,网络异常的,大家如果日常遇到可以分析一波,cpu飙高的我后面会单独补一篇文章介绍如何排查,Full GC和网络问题的之前都写过了,如下:

  • 遇到网络问题你是怎么解决的?安琪拉有二招

  • 百分百面试题:遇到过线上问题没有?

  • 网络问题遇到过吗?TIME_WAIT和CLOSE_WAIT的区别

    答:这二个状态是四次挥手中的状态,TIME_WAIT 是主动关闭的一方发出 FIN 包会经过的状态,CLOSE_WAIT 是被动关闭连接的一端会经过的状态。TIME_WAIT 经过2个MSL(最大报文段生存时间)才能到CLOSE状态,CLOSE_WAIT 如果不发送FIN 报文会一直处在CLOSE_WAIT 状态。所以一般在看机器连接状态,几千个TIME_WAIT 一般是正常的(过2MSL自动关闭),处于CLOSE_WAIT 状态的连接很多,证明有问题。

    三次握手讲一讲

    高频Java面试题解-第二趴_服务端_03三次握手对着上图看下面文字描述

     

  1. 服务端进程启动,准备接收客户端进程的连接请求,此时接收方进入LISTEN(监听)模式;

  2. 三次握手第一步:客户端向服务端发出连接请求报文,这时报文首部SYN 标志位为1,同时设置一个初始序列号seq = x(随机数); 做完这步动作,发送方进入SYN_SENT (同步已发送状态) 。

    名称解释:SYN:同步标志位  seq:包序列编号(每个包都有一个序列号)

    第一次握手客户端发送的报文称为同步请求报文,希望与服务端建立同步连接,SYN报文不携带数据。

  3. 三次握手第二步:服务端收到来自客户端的连接请求报文后,需要确认收货,响应报文中ACK(确认标志位)设置为1,将确认号ack 设置为第一步的请求序列号seq 加1(ack =x+1),另外自己也回客户端一个SYN包(可以建立同步连接),即SYN + ACK包,包序列号seq = y,服务端进入SYN_RCVD(同步收到)状态。

    名词解释:ACK:确认状态位(这里ACK=1),这个一定和ack(32位确认序号,这里ack=x+1)区分开,可以看下面的TCP 报文结构体图,ACK是包的状态标志,ack是确认序号。

  4. 三次握手第三步:客户端收到来自服务端的 SYN + ACK 包,会发送一个ACK 确认包,ACK =1,seq = x+1( 第二步的ack),ack = y+1(第二步的seq+1)。

想一想为什么需要三次握手来建立TCP 连接?

答:第一次握手客户端发送报文给服务端,收到服务端的应答表明客户端发送数据的能力ok;第二次握手服务端发送数据给客户端表示服务端接收数据能力ok(我正常收到你的数据了,告诉你一声);第三次客户端发报文给服务端表明服务端的发送数据的能力也ok,客户端接收数据能力也ok(我能正常收到你的数据,代表你发的数据没问题,我的接收能力也没问题,所以告知你一声)。所以要验证客户端和服务端发送&接收数据的能力都ok至少需要三次握手才能达到。举个实际的例子,比如你投递简历,相当于第一次握手,HR回复你简历已收到,相当于第二次握手,你回复HR已收到批准通知了,这相当于三次握手,HR可以给你安排面试了。因为每一次握手都有消息丢失的风险,所以需要往返至少三次才能保证连接的建立。

  • CountDownLatch、Seamphone、CyclicBarrier 都了解吗?

    首先他们都是基于AQS实现的。

    CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同:

    1. CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行,就像上面主线程等线程池的几个其他线程执行结束再执行;

    2. CyclicBarrier 翻译过来叫回环栅栏,是不是感觉有点晕,我也晕,谁起的这名字,但无所谓,用过就理解了。一般作用是:用于一组线程互相等待至某个状态,然后这一组线程再同时执行;有个例子很生动,Barrier的意思是栅栏,你可以把它想象成起跑的时候有根带子拦在所有参赛者面前,必须所有起跑者都到了带子的位置,大家才能开始一起跑。

    CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。这也是回环的意思。

    CountDownLatch可以用来让一个或多个线程等待,关于这个我写了个例子:

    如下图所示, 一个任务是获取多个电话号码的电话信息,如果是单线程调用,效率不高,那分多个线程调用,主线程阻塞等待多个线程的调用完成,再组合结果。

    CountDownLatch从n -> 0, 线程停止阻塞,继续向下执行。这玩意说实话在多线程协同方面真的很好用,用了才知道Doge Lea还是挺牛逼的,有时候吧就觉得大牛的脑袋真好使,就应该用来写框架,用来做题纯属浪费。

    高频Java面试题解-第二趴_服务端_04

     

    在正规比赛中,终点线的那根带子叫撞线,也称“终点冲线”。但是我们这里为了理解CyclicBarrier,把它用在起跑的时候拦住所有队员,为的是所有人一起跑。

    高频Java面试题解-第二趴_java_05

    例子程序:

    高频Java面试题解-第二趴_老年代_06

    Semaphore其实和锁有点类似,它一般用于控制对某组资源的访问权限。比如很多时候用Semaphore做单机限流。

    如下如图,虽然请求量有1000,但是Semaphore 可以控制同时只有50个线程在执行。

    高频Java面试题解-第二趴_三次握手_07

    信号量,嗯,单机限流可以用。

    以上三个都是基于AQS的,由于AQS实在重要,我会再写一篇专门讲AQS的文章。

  • private、protected、public、关键字你平常怎么用的?

    控制访问权限,很多时候我们会发现属性往往是private的,但是提供public的访问权限,父类中的抽象方法或者希望子类实现的方法是protected的,所以使用了protected了意图是很明显的,子类可访问、可复用、可重写。

    1、public:public表明该数据成员、成员函数是对所有用户开放的,所有用户都可以直接进行调用
    2、private:private表示私有,私有的意思就是除了class自己之外,任何人都不可以直接使用,私有财产神圣不可侵犯嘛,即便是子女,朋友,都不可以使用。
    3、protected:protected对于子女来说,就是public的,可以自由使用,没有任何限制,而对于其他的外部class,protected就相当于private。

    高频Java面试题解-第二趴_老年代_08访问权限表

    另外一种是没有修饰的,子类也不能访问、重写,一般不建议用。

  • Java是值传递还是引用传递,写一段对象拷贝的代码

    值传递。

    参考:你不得不知道的反射(非常重要)

    提到的: BeanUtils.copyProperties(Object dest, Object orig)

  • 内存回收机制了解吗?

  • 新生代和老生代区别?

    二题合并讲。我建了一个JVM群,如果大家有兴趣可以加我好友guofu-angela,备注JVM进群。

    Java虚拟机通过可达性分析来判定对象是否存活。这个算法的基本思想是通过一系列称为"GC Roots"的对象作为起始点,从这些节点向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有与任何引用链相连时,则该对象是不可用的。

    思考什么对象是"GC Roots"的对象?

    熟悉几种常见的垃圾收集方法:

    算法分为"标记"和"清除"两个阶段:首先标记出需要回收的对象,在标记完成后统一回收被标记的对象。它主要不足有两个:一是效率问题,标记和清除两个过程效率都不高。二是空间问题,标记清除后会产生大量不连续内存碎片,碎片太多可能导致要分配较大对象时,无法找到足够的内存空间不得不提前触发一次垃圾收集动作。

    复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块内存用完了,将存活的对象复制到另一块上面,然后把已使用的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等情况,只要移动堆顶指针,按顺序分配内存即可,实现简单运行高效。只是这种算法将内存缩小为原来的一半,代价较高。

    标记过程与"标记-清除"算法一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

    商业虚拟机(如Hotspot)的垃圾收集都采用分代收集算法,根据对象存活周期将内存划分为几块。Java堆分为新生代老年代,这样可以根据年代特点采用适当的收集算法。新生代中每次垃圾收集都有大批对象死去,那就选用复制算法。老年代对象存活率高,没有额外空间进行分配担保,适合使用"标记-清理"或"标记-整理"算法来回收。

    内存分配与回收策略

    新生代GC(Minor GC):发生在新生代的垃圾收集动作,因为Java对象大多朝生夕死,所以Minor GC非常频繁,回收速度也较快。

    老年代GC(Major GC/Full GC):发生在老年代的垃圾收集动作。出现Major GC,经常会伴随至少一次Minor GC。Major GC的速度一般比Minor GC慢。

  • 4. 分代收集算法

  • 3. 标记-整理算法(Mark-Compact)

  • 2. 复制算法

  • 1. 标记-清除算法(Mark-Sweep)

  • 虚拟机栈中栈桢中的局部变量(也叫局部变量表)中引用的对象

  • 方法区中类的静态变量、常量引用的对象

  • 本地方法栈中 JNI (Native方法)引用的对象

  1. 对象优先在Eden分区:
    大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间分配时,虚拟机发起一次Minor GC。GC后对象尝试放入Survivor空间,如果Survivor空间无法放入对象时,只能通过空间分配担保机制提前转移到老年代。

  2. 大对象直接进入老年代:
    大对象指需要大量连续内存空间的Java对象。虚拟机提供-XX:PretenureSizeThreshold参数,如果大于这个设置值对象则直接分配在老年代。这样可以避免新生代中的Eden区及两个Survivor区发生大量内存复制。

  3. 长期存活的对象进入老年代:
    虚拟机会给每个对象定义一个对象年龄计数器。如果对象在Eden出生并且经过一次Minor GC后任然存活,且能够被Survivor容纳,将被移动到Survivor空间中,并且对象年龄设为1.每次Minor GC后对象任然存活在Survivor区中,年龄就加一,当年龄到达-XX:MaxTenuringThreshold参数设定的值时,将会移动到老年代。

  4. 动态年龄判断:
    虚拟机不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold设定的值才会将对象移动到老年代去。如果Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

  5. 空间分配担保:
    在Minor GC前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果条件成立,那么Minor GC是成立的。如果不成立,虚拟机查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次移动到老年代对象的平均大小,如果大于,将尝试一次Minor GC。如果小于,或者HandlePromotionFailure设置值不允许冒险,那将进行一次Full GC。

  6. 垃圾回收器有哪几种? 你们生产环境用的哪种或哪几种?

    高频Java面试题解-第二趴_java_09

    Serial、ParNew、Parallel、CMS、G1

  • 优点:并发收集、低停顿

  • 缺点:产生大量空间碎片、并发阶段会降低吞吐量

    使用方式:

    -XX:+UseConcMarkSweepGC  使用CMS收集器

    -XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长

    -XX:+CMSFullGCsBeforeCompaction  设置进行几次Full GC后,进行一次碎片整理

    -XX:ParallelCMSThreads  设定CMS的线程数量(一般情况~可用CPU数量)

  • G1收集器(Garbage First):对G1 的研究不多,因为这玩意没在生产环境用过,就测试环境试过,感兴趣可以试试。G1 将内存区域划分为多个大小相等的独立区域(Region),使得它可以回收堆中的任何一个区域,而不是像其它的垃圾收集器要么只能回收新生代,要么只能回收老年代。但不是说G1就没有新生代和老年代了,它的每个Region都可以根据需要扮演Eden、Survivor或老年代,垃圾收集器也会针对不同角色的Region采用不同的策略去处理。

    G1的运行过程如上,它也包含了以下4个步骤:

  • Serial: 串行垃圾回收器在进行垃圾回收时,它会持有所有应用程序的线程,冻结所有应用程序线程,使用单个垃圾回收线程来进行垃圾回收工作。串行垃圾回收器是为单线程环境而设计的,如果你的程序不需要多线程,启动串行垃圾回收。

    使用方法:-XX:+UseSerialGC 串联收集

  • ParNew: ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩
    使用方法:-XX:+UseParNewGC ParNew收集器

  • Parallel: Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩

  • Parallel Old 收集器: Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供。

  • CMS(Concurrent Mark Sweep)收集器: 是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记:STW,也是只标记GC Roots直接关联的对象,并修改TAMS的指针值(G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上,垃圾回收时也不会回收这部分空间),这个过程耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。

  2. 并发标记:可达性分析找出要回收的对象,在对象扫描完成后,由于是与用户线程并发执行的,所以存在引用变动的对象,这部分对象会由SATB算法来解决(原始快照,下一篇详细分析)。

  3. 最终标记:STW,处理并发阶段遗留的少量遗留的SATB记录。

  4. 筛选回收:根据用户设定的-XX:MaxGCPauseMillis最大GC停顿时间对Region进行排序,并回收价值最大的Region,尽量保证满足参数设定的值(该值效果和Parallel Scavenge部分讲解的是一样的)。这里的回收算法就是讲存活的对象复制到空的Region中,即G1局部Region之间采用的是复制算法,而整体上采用的是标记整理算法

    特点:

    G1适合上百G的堆空间回收,与CMS的权衡在6~8G之间,较大的堆内存才能凸显G1的优势,

    使用方式:

    可以通过-XX:+UseG1GC参数开启。

  5. 初始标记(CMS initial mark)

  6. 并发标记(CMS concurrent mark)

  7. 重新标记(CMS remark)

  8. 并发清除(CMS concurrent sweep)
    其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
    由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

    特点:

  9. Java线程和操作系统线程什么关系?

    详细内容参考: 阿里面试官问我Java线程和操作系统线程什么关系

  10. 线程有几种状态,线程生命周期讲讲

    高频Java面试题解-第二趴_服务端_10图片

    详细内容参考: 安琪拉教百里守约学并发编程之多线程基础