关于后端:我想进大厂之Java基础夺命连环16问

38次阅读

共计 8037 个字符,预计需要花费 21 分钟才能阅读完成。

说好了面试系列曾经完结了,后果发现还是真香,嗯,认为我发现我的 Java 根底都没写,所以这个就算作续集了,续集第一篇请各位收好。

说说过程和线程的区别?

过程是程序的一次执行,是零碎进行资源分配和调度的独立单位,他的作用是是程序可能并发执行进步资源利用率和吞吐率。

因为过程是资源分配和调度的根本单位,因为过程的创立、销毁、切换产生大量的工夫和空间的开销,过程的数量不能太多,而线程是比过程更小的能独立运行的根本单位,他是过程的一个实体,能够缩小程序并发执行时的工夫和空间开销,使得操作系统具备更好的并发性。

线程根本不领有系统资源,只有一些运行时必不可少的资源,比方程序计数器、寄存器和栈,过程则占有堆、栈。

晓得 synchronized 原理吗?

synchronized 是 java 提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为 监视器锁,应用 synchronized 之后,会在编译之后在同步的代码块前后加上 monitorenter 和 monitorexit 字节码指令,他依赖操作系统底层互斥锁实现。他的作用次要就是实现原子性操作和解决共享变量的内存可见性问题。

执行 monitorenter 指令时会尝试获取对象锁,如果对象没有被锁定或者曾经取得了锁,锁的计数器 +1。此时其余竞争锁的线程则会进入期待队列中。

执行 monitorexit 指令时则会把计数器 -1,当计数器值为 0 时,则锁开释,处于期待队列中的线程再持续竞争锁。

synchronized 是排它锁,当一个线程取得锁之后,其余线程必须期待该线程开释锁后能力取得锁,而且因为 Java 中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换十分耗费性能。

从内存语义来说,加锁的过程会革除工作内存中的共享变量,再从主内存读取,而开释锁的过程则是将工作内存中的共享变量写回主内存。

实际上大部分时候我认为说到 monitorenter 就行了,然而为了更分明的形容,还是再具体一点

如果再深刻到源码来说,synchronized 实际上有两个队列 waitSet 和 entryList。

  1. 当多个线程进入同步代码块时,首先进入 entryList
  2. 有一个线程获取到 monitor 锁后,就赋值给以后线程,并且计数器 +1
  3. 如果线程调用 wait 办法,将开释锁,以后线程置为 null,计数器 -1,同时进入 waitSet 期待被唤醒,调用 notify 或者 notifyAll 之后又会进入 entryList 竞争锁
  4. 如果线程执行结束,同样开释锁,计数器 -1,以后线程置为 null

那锁的优化机制理解吗?

从 JDK1.6 版本之后,synchronized 自身也在一直优化锁的机制,有些状况下他并不会是一个很重量级的锁了。优化机制包含自适应锁、自旋锁、锁打消、锁粗化、轻量级锁和偏差锁。

锁的状态从低到高顺次为 无锁 -> 偏差锁 -> 轻量级锁 -> 重量级锁,降级的过程就是从低到高,降级在肯定条件也是有可能产生的。

自旋锁:因为大部分时候,锁被占用的工夫很短,共享变量的锁定工夫也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换重大影响性能。自旋的概念就是让线程执行一个忙循环,能够了解为就是啥也不干,避免从用户态转入内核态,自旋锁能够通过设置 -XX:+UseSpining 来开启,自旋的默认次数是 10 次,能够应用 -XX:PreBlockSpin 设置。

自适应锁:自适应锁就是自适应的自旋锁,自旋的工夫不是固定工夫,而是由前一次在同一个锁上的自旋工夫和锁的持有者状态来决定。

锁打消:锁打消指的是 JVM 检测到一些同步的代码块,齐全不存在数据竞争的场景,也就是不须要加锁,就会进行锁打消。

锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范畴扩大到整个操作序列之外。

偏差锁:当线程拜访同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏差锁的线程 ID,之后这个线程再次进入同步块时都不须要 CAS 来加锁和解锁了,偏差锁会永远偏差第一个取得锁的线程,如果后续没有其余线程取得过这个锁,持有锁的线程就永远不须要进行同步,反之,当有其余线程竞争偏差锁时,持有偏差锁的线程就会开释偏差锁。能够用过设置 -XX:+UseBiasedLocking 开启偏差锁。

轻量级锁:JVM 的对象的对象头中蕴含有一些锁的标记位,代码进入同步块的时候,JVM 将会应用 CAS 形式来尝试获取锁,如果更新胜利则会把对象头中的状态位标记为轻量级锁,如果更新失败,以后线程就尝试自旋来取得锁。

整个锁降级的过程非常复杂,我尽力去除一些无用的环节,简略来形容整个降级的机制。

简略点说,偏差锁就是通过对象头的偏差线程 ID 来比照,甚至都不须要 CAS 了,而轻量级锁次要就是通过 CAS 批改对象头锁记录和自旋来实现,重量级锁则是除了领有锁的线程其余全副阻塞。

那对象头具体都蕴含哪些内容?

在咱们罕用的 Hotspot 虚拟机中,对象在内存中布局理论蕴含 3 个局部:

  1. 对象头
  2. 实例数据
  3. 对齐填充

而对象头蕴含两局部内容,Mark Word 中的内容会随着锁标记位而发生变化,所以只说存储构造就好了。

  1. 对象本身运行时所需的数据,也被称为 Mark Word,也就是用于轻量级锁和偏差锁的关键点。具体的内容蕴含对象的 hashcode、分代年龄、轻量级锁指针、重量级锁指针、GC 标记、偏差锁线程 ID、偏差锁工夫戳。
  2. 存储类型指针,也就是指向类的元数据的指针,通过这个指针能力确定对象是属于哪个类的实例。

如果是数组的话,则还蕴含了数组的长度

对于加锁,那再说下 ReentrantLock 原理?他和 synchronized 有什么区别?

相比于 synchronized,ReentrantLock 须要显式的获取锁和开释锁,绝对当初根本都是用 JDK7 和 JDK8 的版本,ReentrantLock 的效率和 synchronized 区别根本能够持平了。他们的次要区别有以下几点:

  1. 期待可中断,当持有锁的线程长时间不开释锁的时候,期待中的线程能够抉择放弃期待,转而解决其余的工作。
  2. 偏心锁:synchronized 和 ReentrantLock 默认都是非偏心锁,然而 ReentrantLock 能够通过构造函数传参扭转。只不过应用偏心锁的话会导致性能急剧下降。
  3. 绑定多个条件:ReentrantLock 能够同时绑定多个 Condition 条件对象。

ReentrantLock 基于 AQS(AbstractQueuedSynchronizer 形象队列同步器 ) 实现。别说了,我晓得问题了,AQS 原理我来讲。

AQS 外部保护一个 state 状态位,尝试加锁的时候通过 CAS(CompareAndSwap)批改值,如果胜利设置为 1,并且把以后线程 ID 赋值,则代表加锁胜利,一旦获取到锁,其余的线程将会被阻塞进入阻塞队列自旋,取得锁的线程开释锁的时候将会唤醒阻塞队列中的线程,开释锁的时候则会把 state 从新置为 0,同时以后线程 ID 置为空。

CAS 的原理呢?

CAS 叫做 CompareAndSwap,比拟并替换,次要是通过处理器的指令来保障操作的原子性,它蕴含三个操作数:

  1. 变量内存地址,V 示意
  2. 旧的预期值,A 示意
  3. 筹备设置的新值,B 示意

当执行 CAS 指令时,只有当 V 等于 A 时,才会用 B 去更新 V 的值,否则就不会执行更新操作。

那么 CAS 有什么毛病吗?

CAS 的毛病次要有 3 点:

ABA 问题:ABA 的问题指的是在 CAS 更新的过程中,当读取到的值是 A,而后筹备赋值的时候依然是 A,然而实际上有可能 A 的值被改成了 B,而后又被改回了 A,这个 CAS 更新的破绽就叫做 ABA。只是 ABA 的问题大部分场景下都不影响并发的最终成果。

Java 中有 AtomicStampedReference 来解决这个问题,他退出了预期标记和更新后标记两个字段,更新时不光查看值,还要查看以后的标记是否等于预期标记,全副相等的话才会更新。

循环工夫长开销大:自旋 CAS 的形式如果长时间不胜利,会给 CPU 带来很大的开销。

只能保障一个共享变量的原子操作:只对一个共享变量操作能够保障原子性,然而多个则不行,多个能够通过 AtomicReference 来解决或者应用锁 synchronized 实现。

好,说说 HashMap 原理吧?

HashMap 次要由数组和链表组成,他不是线程平安的。外围的点就是 put 插入数据的过程,get 查问数据以及扩容的形式。JDK1.7 和 1.8 的次要区别在于头插和尾插形式的批改,头插容易导致 HashMap 链表死循环,并且 1.8 之后退出红黑树对性能有晋升。

put 插入数据流程

往 map 插入元素的时候首先通过对 key hash 而后与数组长度 - 1 进行与运算((n-1)&hash),都是 2 的次幂所以等同于取模,然而位运算的效率更高。找到数组中的地位之后,如果数组中没有元素间接存入,反之则判断 key 是否雷同,key 雷同就笼罩,否则就会插入到链表的尾部,如果链表的长度超过 8,则会转换成红黑树,最初判断数组长度是否超过默认的长度 * 负载因子也就是 12,超过则进行扩容。

get 查问数据

查问数据相对来说就比较简单了,首先计算出 hash 值,而后去数组查问,是红黑树就去红黑树查,链表就遍历链表查问就能够了。

resize 扩容过程

扩容的过程就是对 key 从新计算 hash,而后把数据拷贝到新的数组。

那多线程环境怎么应用 Map 呢?ConcurrentHashmap 理解过吗?

多线程环境能够应用 Collections.synchronizedMap 同步加锁的形式,还能够应用 HashTable,然而同步的形式显然性能不达标,而 ConurrentHashMap 更适宜高并发场景应用。

ConcurrentHashmap 在 JDK1.7 和 1.8 的版本改变比拟大,1.7 应用 Segment+HashEntry 分段锁的形式实现,1.8 则摈弃了 Segment,改为应用 CAS+synchronized+Node 实现,同样也退出了红黑树,防止链表过长导致性能的问题。

1.7 分段锁

从构造上说,1.7 版本的 ConcurrentHashMap 采纳分段锁机制,外面蕴含一个 Segment 数组,Segment 继承与 ReentrantLock,Segment 则蕴含 HashEntry 的数组,HashEntry 自身就是一个链表的构造,具备保留 key、value 的能力能指向下一个节点的指针。

实际上就是相当于每个 Segment 都是一个 HashMap,默认的 Segment 长度是 16,也就是反对 16 个线程的并发写,Segment 之间互相不会受到影响。

put 流程

其实发现整个流程和 HashMap 十分相似,只不过是先定位到具体的 Segment,而后通过 ReentrantLock 去操作而已,前面的流程我就简化了,因为和 HashMap 基本上是一样的。

  1. 计算 hash,定位到 segment,segment 如果是空就先初始化
  2. 应用 ReentrantLock 加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保障肯定获取锁胜利
  3. 遍历 HashEntry,就是和 HashMap 一样,数组中 key 和 hash 一样就间接替换,不存在就再插入链表,链表同样

get 流程

get 也很简略,key 通过 hash 定位到 segment,再遍历链表定位到具体的元素上,须要留神的是 value 是 volatile 的,所以 get 是不须要加锁的。

1.8CAS+synchronized

1.8 摈弃分段锁,转为用 CAS+synchronized 来实现,同样 HashEntry 改为 Node,也退出了红黑树的实现。次要还是看 put 的流程。

put 流程

  1. 首先计算 hash,遍历 node 数组,如果 node 是空的话,就通过 CAS+ 自旋的形式初始化
  2. 如果以后数组地位是空则间接通过 CAS 自旋写入数据
  3. 如果 hash==MOVED,阐明须要扩容,执行扩容
  4. 如果都不满足,就应用 synchronized 写入数据,写入数据同样判断链表、红黑树,链表写入和 HashMap 的形式一样,key hash 一样就笼罩,反之就尾插法,链表长度超过 8 就转换成红黑树

get 查问

get 很简略,通过 key 计算 hash,如果 key hash 雷同就返回,如果是红黑树依照红黑树获取,都不是就遍历链表获取。

volatile 原理晓得吗?

相比 synchronized 的加锁形式来解决共享变量的内存可见性问题,volatile 就是更轻量的抉择,他没有上下文切换的额定开销老本。应用 volatile 申明的变量,能够确保值被更新的时候对其余线程立即可见。volatile 应用内存屏障来保障不会产生指令重排,解决了内存可见性的问题。

咱们晓得,线程都是从主内存中读取共享变量到工作内存来操作,实现之后再把后果写会主内存,然而这样就会带来可见性问题。举个例子,假如当初咱们是两级缓存的双核 CPU 架构,蕴含 L1、L2 两级缓存。

  1. 线程 A 首先获取变量 X 的值,因为最后两级缓存都是空,所以间接从主内存中读取 X,假如 X 初始值为 0,线程 A 读取之后把 X 值都批改为 1,同时写回主内存。这时候缓存和主内存的状况如下图。

  1. 线程 B 也同样读取变量 X 的值,因为 L2 缓存曾经有缓存 X =1,所以间接从 L2 缓存读取,之后线程 B 把 X 批改为 2,同时写回 L2 和主内存。这时候的 X 值入下图所示。

    那么线程 A 如果再想获取变量 X 的值,因为 L1 缓存曾经有 x = 1 了,所以这时候变量内存不可见问题就产生了,B 批改为 2 的值对 A 来说没有感知。

那么,如果 X 变量用 volatile 润饰的话,当线程 A 再次读取变量 X 的话,CPU 就会依据缓存一致性协定强制线程 A 从新从主内存加载最新的值到本人的工作内存,而不是间接用缓存中的值。

再来说内存屏障的问题,volatile 润饰之后会退出不同的内存屏障来保障可见性的问题能正确执行。这里写的屏障基于书中提供的内容,然而实际上因为 CPU 架构不同,重排序的策略不同,提供的内存屏障也不一样,比方 x86 平台上,只有 StoreLoad 一种内存屏障。

  1. StoreStore 屏障,保障下面的一般写不和 volatile 写产生重排序
  2. StoreLoad 屏障,保障 volatile 写与前面可能的 volatile 读写不产生重排序
  3. LoadLoad 屏障,禁止 volatile 读与前面的一般读重排序
  4. LoadStore 屏障,禁止 volatile 读和前面的一般写重排序

那么说说你对 JMM 内存模型的了解?为什么须要 JMM?

自身随着 CPU 和内存的倒退速度差别的问题,导致 CPU 的速度远快于内存,所以当初的 CPU 退出了高速缓存,高速缓存个别能够分为 L1、L2、L3 三级缓存。基于下面的例子咱们晓得了这导致了缓存一致性的问题,所以退出了缓存一致性协定,同时导致了内存可见性的问题,而编译器和 CPU 的重排序导致了原子性和有序性的问题,JMM 内存模型正是对多线程操作下的一系列标准束缚,因为不可能让陈雇员的代码去兼容所有的 CPU,通过 JMM 咱们才屏蔽了不同硬件和操作系统内存的拜访差别,这样保障了 Java 程序在不同的平台下达到统一的内存拜访成果,同时也是保障在高效并发的时候程序可能正确执行。

原子性:Java 内存模型通过 read、load、assign、use、store、write 来保障原子性操作,此外还有 lock 和 unlock,间接对应着 synchronized 关键字的 monitorenter 和 monitorexit 字节码指令。

可见性:可见性的问题在下面的答复曾经说过,Java 保障可见性能够认为通过 volatile、synchronized、final 来实现。

有序性:因为处理器和编译器的重排序导致的有序性问题,Java 通过 volatile、synchronized 来保障。

happen-before 规定

尽管指令重排进步了并发的性能,然而 Java 虚构机会对指令重排做出一些规定限度,并不能让所有的指令都随便的扭转执行地位,次要有以下几点:

  1. 单线程每个操作,happen-before 于该线程中任意后续操作
  2. volatile 写 happen-before 与后续对这个变量的读
  3. synchronized 解锁 happen-before 后续对这个锁的加锁
  4. final 变量的写 happen-before 于 final 域对象的读,happen-before 后续对 final 变量的读
  5. 传递性规定,A 先于 B,B 先于 C,那么 A 肯定先于 C 产生

说了半天,到底工作内存和主内存是什么?

主内存能够认为就是物理内存,Java 内存模型中理论就是虚拟机内存的一部分。而工作内存就是 CPU 缓存,他有可能是寄存器也有可能是 L1\L2\L3 缓存,都是有可能的。

说说 ThreadLocal 原理?

ThreadLocal 能够了解为线程本地变量,他会在每个线程都创立一个正本,那么在线程之间拜访外部正本变量就行了,做到了线程之间相互隔离,相比于 synchronized 的做法是用空间来换工夫。

ThreadLocal 有一个动态外部类 ThreadLocalMap,ThreadLocalMap 又蕴含了一个 Entry 数组,Entry 自身是一个弱援用,他的 key 是指向 ThreadLocal 的弱援用,Entry 具备了保留 key value 键值对的能力。

弱援用的目标是为了避免内存泄露,如果是强援用那么 ThreadLocal 对象除非线程完结否则始终无奈被回收,弱援用则会在下一次 GC 的时候被回收。

然而这样还是会存在内存泄露的问题,如果 key 和 ThreadLocal 对象被回收之后,entry 中就存在 key 为 null,然而 value 有值的 entry 对象,然而永远没方法被拜访到,同样除非线程完结运行。

然而只有 ThreadLocal 应用失当,在应用完之后调用 remove 办法删除 Entry 对象,实际上是不会呈现这个问题的。

那援用类型有哪些?有什么区别?

援用类型次要分为强脆弱虚四种:

  1. 强援用指的就是代码中普遍存在的赋值形式,比方 A a = new A()这种。强援用关联的对象,永远不会被 GC 回收。
  2. 软援用能够用 SoftReference 来形容,指的是那些有用然而不是必须要的对象。零碎在产生内存溢出前会对这类援用的对象进行回收。
  3. 弱援用能够用 WeakReference 来形容,他的强度比软援用更低一点,弱援用的对象下一次 GC 的时候肯定会被回收,而不论内存是否足够。
  4. 虚援用也被称作幻影援用,是最弱的援用关系,能够用 PhantomReference 来形容,他必须和 ReferenceQueue 一起应用,同样的当产生 GC 的时候,虚援用也会被回收。能够用虚援用来治理堆外内存。

线程池原理晓得吗?

首先线程池有几个外围的参数概念:

  1. 最大线程数 maximumPoolSize
  2. 外围线程数 corePoolSize
  3. 沉闷工夫 keepAliveTime
  4. 阻塞队列 workQueue
  5. 回绝策略 RejectedExecutionHandler

当提交一个新工作到线程池时,具体的执行流程如下:

  1. 当咱们提交工作,线程池会依据 corePoolSize 大小创立若干工作数量线程执行工作
  2. 当工作的数量超过 corePoolSize 数量,后续的工作将会进入阻塞队列阻塞排队
  3. 当阻塞队列也满了之后,那么将会持续创立 (maximumPoolSize-corePoolSize) 个数量的线程来执行工作,如果工作解决实现,maximumPoolSize-corePoolSize 额定创立的线程期待 keepAliveTime 之后被主动销毁
  4. 如果达到 maximumPoolSize,阻塞队列还是满的状态,那么将依据不同的回绝策略对应解决

回绝策略有哪些?

次要有 4 种回绝策略:

  1. AbortPolicy:间接抛弃工作,抛出异样,这是默认策略
  2. CallerRunsPolicy:只用调用者所在的线程来解决工作
  3. DiscardOldestPolicy:抛弃期待队列中最近的工作,并执行当前任务
  4. DiscardPolicy:间接抛弃工作,也不抛出异样
正文完
 0