35kJava开发岗:基础篇

Posted java_wxid

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了35kJava开发岗:基础篇相关的知识,希望对你有一定的参考价值。

系列文章:文章以35k为备战面试背景,薪资参考坐标:上海;参考时间:2022-07;每个地方,每个时间段薪资待遇都不一样,文章仅做面试参考,具体能否谈到35k取决于面试表现和平时的积累。
点击:【第一章:35kJava开发岗:基础篇

HashMap、Synchronized、ThreadLocal、AQS、线程池、JVM内存模型、内存屏障、class文件结构、类加载 机制、双亲委派、垃圾回收算法、垃圾回收器、空间分配担保策略、安全点、JIT技术、可达性分析、强软弱虚引用、gc的过程、三色标记、跨代引用、 逃逸分析、 内存泄漏与溢出、JVM线上调优经验。

点击:【第二章:35kJava开发岗:MySQL篇

隔离级别、ACID底层实现原理、 一致性非锁定读(MVCC的原理)、BufferPool缓存机制、filesort过程、 离散读、ICP优化、全文检索、 行锁、表锁、间隙锁、死锁、主键自增长实现原理、索引数据结构、SQL优化、索引失效的几种情况、聚集索引、辅助索引、覆盖索引、联合索引、redo log、bin log、undolog、分布式事务、SQL的执行流程、重做日志刷盘策略、有mysql调优、分库分表、主从复制、读写分离、高可用。

点击:【第三章:35kJava开发岗:Redis篇

多路复用模式、单线程模型、简单字符串、链表、字典、跳跃表、压缩列表、encoding属性编码、持久化、布隆过滤器、分布式寻址算法、过期策略、内存淘汰策略 、Redis与数据库的数据一致性、Redis分布式锁、热点数据缓存、哨兵模式、集群模式、多级缓存架构、并发竞争、主从架构、集群架构及高可用、缓存雪崩、 缓存穿透、缓存失效。

点击:【第四章:35kJava开发岗:MQ篇

RabbitMQ、RockerMQ、Kafka 三种消息中间件出现的消息可靠投递、消息丢失、消息顺序性、消息延迟、过期失效、消息队列满了、消息高可用等问题的解决方案。

【第五章:35kJava开发岗:Spring篇】

待补充


提示:系列文章还未全部完成,后续的文章,会慢慢补充进去的。

文章目录

这里总结一下35k的Java开发岗需要掌握的面试题,帮助大家快速复习,突破面试瓶颈。本章主讲Java基础知识点,知识点有:HashMap、Synchronized、ThreadLocal、AQS、线程池、JVM内存模型、内存屏障、class文件结构、类加载 机制、双亲委派、垃圾回收算法、垃圾回收器、空间分配担保策略、安全点、JIT技术、可达性分析、强软弱虚引用、gc的过程、三色标记、跨代引用、 逃逸分析、 内存泄漏与溢出、JVM线上调优经验。大致估算可以讲八小时左右,作为备战面试的基础知识点还是很不错的。35k薪资参考的坐标:上海,参考时间:2022年7月

HashMap

hashmap几乎是Java面试必问题,相关的知识点其实有很多,更为详细的hashmap知识点,我也有写,全部讲一遍,差不多要一个小时以上,有时间的同学可以去看看,这里提供地址:https://blog.csdn.net/java_wxid/article/details/124788118,面试官想问的可能就那么几个,另外还需要控制hashmap讲解的时长,挑几个比较重要的,进行讲解即可,下面由浅到深讲解,专门针对面试题,归总的知识点列举出来。

HashMap底层实现

向HashMap中添加一个元素时,当前元素的key会调用hashCode方法来决定它在数组中存放的位置。如果这个位置没有其他元素,会把这个键值对直接放到一个node类型的数组中,这个数组就是hashmap底层基础的数据结构。如果这个位置有其他元素,会继续拿着这个key调用equals方法和这个位置已存在的元素key进行对比,对比二个元素的key。key一样,返回true,原来的value值会被替换成新的value。key不一样,返回flase,这个位置就用链表的形式把多个元素串起来存放。

jdk1.7版本的HashMap数据结构就是数组加链表的形式存储元素的,但是会有弊端,当链表中的数据较多时,查询的效率会下降。所以JDK1.8版本做了一个升级,当链表长度大于8,并且数组长度大于64时,会转换为红黑树。因为红黑树需要进行左旋,右旋,变色操作来保持平衡,如果当数组长度小于64,使用数组加链表比使用红黑树查询速度要更快、效率更高。

在HashMap源码有这样一段描述,大致意思是说在理想状态下受随机分布的hashCode影响,链表中的节点遵循泊松分布,节点数是8的概率接近千分之一,这个时候链表的性能很差,所以在这种比较罕见和极端的情况下才会把链表转变为红黑树,大部分情况下HashMap还是使用链表,如果理想情况下,均匀分布,节点数不到8就已经自动扩容了。

1.7版本和1.8版本的差异

jdk1.7的hashmap有二个无法忽略的问题。

  • 第一个是扩容的时候需要rehash操作,将所有的数据重新计算HashCode,然后赋给新的HashMap,rehash的过程是非常耗费时间和空间的。

  • 第二个是当并发执行扩容操作时会造成环形链和数据丢失的情况,开多个线程不断进行put操作,当旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,就是因为头插法,所以最后的结果打乱了插入的顺序,就有可能发生环形链和数据丢失的问题,引起死循环,导致CPU利用率接近100%。

在JDK1.8中,对HashMap这二点进行了优化。

  • 第一点是经过rehash之后元素的位置,要么是在原位置,要么是原位置+原数组长度。不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了。在数组的长度扩大到原来的2倍, 4倍,8倍时,在resize(也就是length - 1)这部分,相当于在高位新增一个或多个1bit。

    举个例子,hashmap默认的初始长度是16,负载因子是0.75,当元素被使用75%以上时,触发扩容操作,并且每次扩容一倍。扩容时:将旧数组中的元素转换后,填充到新数组中。通过底层获取索引indexfor方法里面有个(length -1)公式,取它的二进制,它的二进制位后八位是0000 1111,扩容二倍到32,通过公式(length -1)取31的二进制,它的后八位0001 1111,可以发现它的高位进的是1,然后和原来的hash码进行与操作,这样元素在数组中映射的位置要么不变,要不就是在原位置再移动2次幂的位置。

    高位上新增的是1的话索引变成原位置+原数组长度,是0的话索引没变。这样既省去了重新计算hash值的时间,而且由于高位上新增的1bit是0还是1,可以认为是随机的,复杂度更高,从而让分布性更高些。

  • 第二点,发生hash碰撞,不再采用头插法方式,而是直接插入链表尾部,因此不会出现环形链表的情况,但是在多线程环境下,会发生数据覆盖的情况。

    举个例子,如果没有hash碰撞的时候,它会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,线程A会把线程B插入的数据给覆盖,导致数据发生覆盖的情况,发生线程不安全。

并发修改异常解决方案

HashMap在高并发场景下会出现并发修改异常,导致原因:并发争取修改导致,一个线程正在写,一个线程过来争抢,导致线程写的过程被其他线程打断,导致数据不一致。

  • 第一种解决方案:使用HashTable:HashTable是线程安全的,只不过实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁。多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。

  • 第二种解决方案:使用工具类Collections.synchronizedMap(new HashMap<>());和Hashtable一样,实现上在操作HashMap时自动添加了synchronized来实现线程同步,都对整个map进行同步,在性能以及安全性方面不如ConcurrentHashMap。

  • 第三种解决方案:使用写时复制(CopyOnWrite):往一个容器里面加元素的时候,不直接往当前容器添加,而是先将当前容器的元素复制出来放到一个新的容器中,然后新的元素添加元素,添加完之后,再将原来容器的引用指向新的容器,这样就可以对它进行并发的读,不需要加锁,因为当前容器不添加任何元素。利用了读写分离的思想,读和写是不同的容器。缺点也很明显,会有内存占用问题,在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存。会有数据一致性问题,CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。

  • 第四种解决方案:使用ConcurrentHashMap:ConcurrentHashMap大量的利用了volatile,CAS等技术来减少锁竞争对于性能的影响。在JDK1.7版本中ConcurrentHashMap避免了对全局加锁,改成了局部加锁(分段锁),分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。不过这种结构的带来的副作用是Hash的过程要比普通的HashMap要长。所以在JDK1.8版本中CurrentHashMap内部中的value使用volatile修饰,保证并发的可见性以及禁止指令重排,只不过volatile不保证原子性,使用为了确保原子性,采用CAS(比较交换)这种乐观锁来解决。

加载因子

加载因子是用来判断当前HashMap<K,V>中存放的数据量,默认的加载因子是0.75。

加载因子比较大,扩容发生的频率比较低,浪费的空间比较小,发生hash冲突的几率比较大。

  • 比如,加载因子是1的时候,hashmap长度为128,实际存储元素的数量在64至128之间时间段比较多,这个时间段发生hash冲突比较多,造成数组中其中一条链表比较长,会影响性能。

加载因子比较小,扩容发生的频率比较高,浪费的空间比较多,发生hash冲突的几率比较小。

  • 比如,加载因子是0.5的时候,hashmap长度为128,当数量达到65的时候会触发扩容,扩容后为原理的256,256里面只存储了65个,浪费了。综合了一下,取了一个平均数0.75作为加载因子。

长度恒定为2的n次方

HashMap的数组长度恒定为2的n次方,也就是说只会为16,32,64这种数。即便你给的初始值是13,最后数组长度也会变成16,它会取你传进来的数,最近一个2的n次方的数。这么设计的目的主要是为了解决底层运算后的值可以落到数组的每个下标上面。
hashMap获取索引的方法:

//indexFor中的h是hashCode通过变换之后的值,是一个32位的二进制数
public static int indexFor(int h, int length) 
    return h & (length-1);

HashMap中运算数组的位置,使用的是length-1,每次扩容会把原数组的长度*2,在二进制上的表现就是高位进1,并且后四位始终都是1111。

初始长度为16的数组,对应的length-1就是15,原数组15二进制后八位为0000 1111。
扩容后的长度为32的数组,对应的length-1就是31,二进制就变成了0001 1111。
再次扩容长度为64的数组,对应的length-1就是63,二进制是0011 1111

假设hashMap容量为16
hash值&运算:

11001110 11001111 00010011 11110001(hash值)
&
00000000 00000000 00000000 00001111(16-1的2进制)
=
00000000 00000000 00000000 00000001

hash的2进制的后4位和1111比较,hash值的后4位范围是0000-1111之间,与上1111,最后的值是在0000-1111,也就是0-15之间。这样就保证运算后的值可以落到数组的每一个下标。

如果数组长度不是2的幂次,后四位就不可能是1111,0000~1111的一个数和有可能不是1111的数进行&运算,数组的某几位下标就有可能永远不会有值,这就没法保证运算后的值可以落到数组的每个下标上面。

散列均匀分布

hashMap获取索引的indexFor方法里面的h是hashCode通过变换之后的值,是一个32位的二进制数,如果直接用如此长的二进制数和目标length-1直接进行与运算,结果会导致高位会大量丢失。

假如我们以16位为划分,任何两个高16位不一样,低16位一样的数。这两个数的hashCode与length-1做与运算(hashCode & length-1),结果会是一样的,这样的两个数,却产生了相同的hash结果,发生hash冲突。

于是hashMap想到了一种处理方式:底层算法通过让32位hashcode中保持高16位不变,高16与低16异或结果,作为新的低16位,然后用hash得到的结果(int h)传入方法indexFor获取到hashMap的索引。

计算中只有低位16位参与&运算,计算效率高,同时也保证的hash的高16位参与了索引运算,这样得到的索引能呈较为理想的散列分布,在将条目放入hashMap中时,最大限度避免hash碰撞。

static final int hash(Object key) 
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);//把hash值异或了hash值右移16位,即取高16位

绝大多数情况下length一般都小于2^16即小于65536,所以indexFor方法中return h & (length-1)的结果始终是h的低16位与(length-1)进行&运算。hashmap为了考虑性能的设计还是非常精妙的。

hashmap优化

对hashmap使用的优化,我个人看法有五点。

  • 第一点,建议采用短String,Integer这样的类作为键。特别是String,他是不可变的,也是final的,而且已经重写了equals和hashCode方法,契合HashMap要求的计算hashCode的不可变性要求,核心思想就是保证键值的唯一性,不变性,其次是不可变性还有诸如线程安全的问题,这么定义键,可以最大限度的减少碰撞的出现。如果hashCode不冲突,那查找效率很高,但是如果hashCode一旦冲突,要调用equals一个字节一个自己的去比较,key越短效率越高。

  • 第二点不使用for循环遍历map,而是使用迭代器遍历Map,使用迭代器遍历entrySet在各个数量级别效率都比较高。

  • 第三点使用线程安全的ConcurrentHashMap来删除Map中的元素,或者在迭代器Iterator遍历时,使用迭代器iterator.remove()来删除元素。不可以for循环遍历删除,否则会产生并发修改异常CME。

  • 第四点考虑加载因子地设定初始大小,设定时一定要考虑加载因子的存在,使用的时候最好估算存储的大小。可以使用Maps.newHashMapWithExpectedSize(预期大小)来创建一个HashMap,计算的过程guava会帮我们完成,Guava的做法是把默认容量的数字设置成预期大小 / 0.75F + 1.0F

  • 第五点减小加载因子​,如果Map是一个长期存在而不是每次动态生成的,而里面的key又是没法预估的,那可以适当加大初始大小,同时减少加载因子,降低冲突的机率。毕竟如果是长期存在的map,浪费点数组大小不算啥,降低冲突概率,减少比较的次数更重要。

Synchronized

Synchronized是Java高频面试题,相关的知识点其实有很多,更为详细的Synchronized知识点,我也有写,全部讲一遍,差不多要一个小时以上,有时间的同学可以去看看,这里提供地址:https://liaozhiwei.blog.csdn.net/article/details/124900072,面试官想问的主要是锁的升级过程,下面由浅到深讲解,专门针对面试题,归总的知识点列举出来。

定义

Synchronized是Java语言的关键字,它保证同一时刻被Synchronized修饰的代码最多只有1个线程执行。

应用场景

synchronized如果加在方法上/对象上,那么,它作用的对象是非静态的,它取得的锁是对象锁;
synchronized如果作用的对象是一个静态方法或一个类,它取到的锁是类锁,这个类所有的对象用的是同一把锁。
每个对象只有一个锁,谁拿到这个锁,谁就可以运行它所控制的那段代码。

对象加锁实现原理

在Java的设计中,每一个Java对象就带了一把看不见的锁,可以叫做内部锁或者Monitor锁,Synchronized在JVM里的实现是基于进入和退出Monitor对象来实现方法同步和代码块同步的。Monitor可以把它理解为一个同步工具,所有的Java对象是天生的Monitor,Monitor监视器对象就是存在于每个Java对象的对象头MarkWord里面,也就是存储指针的指向,Synchronized锁便是通过这种方式获取锁的。

JDK6以前

Synchronized加锁是通过对象内部的监视器锁来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要比较长的时间。

实现步骤

第一步,当有二个线程A、线程B都要开始给变量+1,要进行操作的时候,发现方法上加了Synchronized锁,这时线程调度到A线程执行,A线程就抢先拿到了锁,当前已经获取到锁资源的线程被称为Owner,将MonitorObject中的_owner设置成A线程。

第二步,将mark word设置为Monitor对象地址,锁标志位改为10;

第三步,将B线程阻塞,放到ContentionList队列中。因为JVM每次从Waiting Queue的尾部取出一个线程放到OnDeck中,作为候选者,但是如果并发比较高,WaitingQueue会被大量线程执行CAS操作,为了降低对尾部元素的竞争,将WaitingQueue拆分成ContentionList和EntryList二个队列,所有请求锁的线程首先尝试自旋获取锁,如果获取不到,被放在ContentionList这个竞争队列中,ContentionList中那些有资格成为候选资源的线程被移动到EntryList中。ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的,Linux内核下采用pthread_mutex_lock内核函数实现的。

第四步,作为Owner的A线程执行过程中,可能调用wait释放锁,这个时候A线程进入WaitSet,等待被唤醒。

JDK6版本及以后

Sun程序员发现大部分程序大多数时间都不会发生多个线程同时访问竞态资源的情况,大多数对象的加锁和解锁都是在特定的线程中完成,出现线程竞争锁的情况概率比较低,比例非常高,所以引入了偏向锁和轻量级锁。

64位JVM下的对象结构描述:
对象头的最后两位存储了锁的标志位
没加锁状态,锁标志位01,是否偏向是0,对象头里存储的是对象本身的哈希码。
偏向锁状态,锁标志位01,是否偏向是1,存储的是当前占用对象的线程ID。
轻量级锁状态,锁标志位00,存储指向线程栈中锁记录的指针。
重量级锁状态,锁标志位10,存储的就是重量级锁的指针了。

对象从无锁到偏向锁转化的过程

第一步,检测MarkWord是否为可偏向状态,是偏向锁是1,锁标识位是01。
第二步,如果是可偏向状态,测试线程ID是不是当前线程ID。如果是,就直接执行同步代码块。
第三步,如果测试线程ID不是当前线程ID,就通过CAS操作竞争锁,竞争成功,就把MarkWord的线程ID替换为当前线程ID。
第四步,如果CAS竞争锁失败,证明有别的线程持有锁,假设线程B来CAS失败了,这个时候启动偏向锁撤销(revokebias),让A线程在全局安全点阻塞,获得偏向锁的线程被挂起,有点类似于GC前线程在安全点阻塞。
第五步,接着遍历线程栈,查看有没有锁对象的锁记录LockRecord,如果有LockRecord,需要修复锁记录和Markword,让它变成无锁状态。恢复A线程,将是否为偏向锁状态改为0,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程,继续往下执行同步代码块。

安全点是jvm为了保证在垃圾回收的过程中引用关系不会发生变化,设置的安全状态,在这个状态上会暂停所有线程工作。一般有循环的末尾,方法临返回前,调用方法的call指令后,可能抛异常的位置,这些位置都可以算是安全点。

轻量级锁升级

轻量级锁升级过程是,在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的MarkWord的拷贝,拷贝无锁状态对象头中的MarkWord复制到锁记录中。

  • 这么做是因为在申请对象锁时,需要以该值作为CAS的比较条件。
  • 同时在升级到重量级锁的时候,能通过这个比较,判定是否在持有锁的过程中,这个锁被其他线程申请过,如果被其他线程申请了,在释放锁的时候要唤醒被挂起的线程。
  • 无锁的markword中可能存有hashCode,锁撤销之后必须恢复,这个markword要用于锁撤销后的还原。如果轻量级锁解锁为无锁状态,直接将拷贝的markword CAS修改到锁对象的markword里面就可以了。

拷贝成功后,虚拟机将使用CAS操作把对象中对象头MarkWord替换为指向锁记录的指针,然后把锁记录空间里的owner指针指向加锁的对象,如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象MarkWord的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象MarkWord中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。如果不是说明多个线程竞争锁,进入自旋,若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,MarkWord中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。一般来说,同步代码块内的代码应该很快就执行结束,这时候线程B自旋一段时间是很容易拿到锁的,但是如果不巧,没拿到,自旋其实就是死循环,很耗CPU的,因此就直接转成重量级锁咯,这样就不用了线程一直自旋了。

自旋锁

自旋锁不是一种锁状态,而是一种策略。线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。

引入自旋锁,当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。

自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。

自旋的次数必须要有一个限度,如果自旋超过了定义的限度仍然没有获取到锁,就应该被挂起。但是这个限度不能固定,程序锁的状况是不可预估的,所以JDK1.6引入自适应的自旋锁,线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少,甚至省略掉自旋过程,以免浪费处理器资源。

通过–XX:+UseSpinning参数来开启自旋(JDK1.6之前默认关闭自旋)。
通过–XX:PreBlockSpin修改自旋次数,默认值是10次。

重量级锁

当一个线程在等锁时会不停的自旋(底层就是一个while循环),当自旋的线程达到CPU核数的1/2时,就会升级为重量级锁。

将锁标志为置为10,将MarkWord中指针指向重量级的monitor,阻塞所有没有获取到锁的线程。

Synchronized是通过对象内部的监视器锁(Monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的MutexLock来实现的,操作系统实现线程之间的切换这就需要从用户态转换到核心态,状态之间的转换需要比较长的时间,这就是为什么Synchronized效率低的原因,这种依赖于操作系统MutexLock所实现的锁我们称之为“重量级锁”。

重量级锁的加锁-等待-撤销流程:
曾经获得过锁的线程,被唤醒后,优先得到锁。

举个例子,假设有A,B,C三个线程依次进入synchronized区,并且A已经膨胀成重量级锁。如果有一个线程 a 先进入 synchronized , 但是调用了 wait释放锁,这是线程 b 进入了 synchronized,b还在synchronized中执行,c线程又进来了。此时 a 在 wait_set ,b 不在任何队列,c 在 cxq_list ,假如 b 调用 notify唤醒线程,会把 a 插到 c 前面,也就是 b 退出synchronized的时候,会唤醒 a,a退出之后再唤醒 c。

重量级锁撤销之后是无锁状态,撤销锁之后会清除创建的monitor对象并修改markword,这个过程需要一段时间。Monitor对象是通过GC来清除的。GC清除掉monitor对象之后,就会撤销为无锁状态。

引入偏向锁的好处
  • 偏向锁的好处是并发度很低的情况下,同一个线程获取锁不需要内存拷贝的操作,免去了轻量级锁的在线程栈中建LockRecord,拷贝MarkDown的内容。

  • 免了重量级锁的底层操作系统用户态到内核态的切换,节省毫无意义的请求锁的时间。

  • 另外Hotspot也做了另一项优化,基于锁对象的epoch批量偏移和批量撤销偏移,这样大大降低了偏向锁的CAS和锁撤销带来的损耗。因为基于epoch批量撤销偏向锁和批量加偏向锁能大幅提升吞吐量,但是并发量特别大的时候性能就没有什么特别的提升了。

  • 偏向锁减少CAS操作,降低Cache一致性流量,CAS操作会延迟本地调用。

为什么这么说呢?这要从SMP(对称多处理器)架构说起,所有的CPU会共享一条系统总线BUS,靠此总线连接主内存,每个核都有自己的一级缓存,每个核相对于BUS对称分布。
举个例子,我电脑是六核的,假设一个核是Core1,一个核是Core2,这二个核可能会同时把主存中某个位置的值Load到自己的一级缓存中。当Core1在自己的L1Cache中修改这个位置的值时,会通过总线,使Core2中L1Cache对应的值“失效”,而Core2一旦发现自己L1Cache中的值失效,也就是所谓的Cache命中缺失,一旦发现失效就会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信叫做“Cache一致性流量”。如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个CoreCAS成功时必然会引起总线风暴,这就是所谓的本地延迟。

所以偏向锁比较适用于只有一个线程访问同步块场景。

引入轻量级的好处

对于绝大部分的锁,在整个同步周期内都是不存在竞争的。如果没有竞争,轻量级锁通过CAS操作成功,避免了使用互斥量的开销。

对于竞争的线程不会阻塞,提高了程序的响应速度。

如果确实存在锁竞争,始终得不到锁竞争的线程使用自旋会消耗CPU,除了互斥量的本身开销外,还额外发生了CAS操作的开销,轻量级锁反而会比传统的重量级锁更慢。

所以轻量级追求的是响应时间,同步块执行速度非常快的场景。

ThreadLocal

定义

ThreadLocal叫做线程变量,这个变量对其他线程而言是隔离的,是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,每个线程可以访问自己内部的副本变量。

ThreadLocal与Synchronized的区别

1、Synchronized用于线程间的数据共享,ThreadLocal用于线程间的数据隔离。

2、Synchronized是利用锁的机制,让变量或代码块在某一时该只能被一个线程访问,用于在多个线程间通信时能够获得数据共享。ThreadLocal为每一个线程都提供了变量的副本,让每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。

底层实现

在 Thread 类中嵌入一个 ThreadLocalMap,ThreadLocalMap 就是一个容器,存储的就是这个 Thread 类专享的数据。

ThreadLocalMap底层结构

static class ThreadLocalMap 

        static class Entry extends WeakReference<ThreadLocal<?>> 
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) 
                super(k);
                value = v;
            
        
        ……
        

ThreadLocal在保存的时候会把自己当做Key存在ThreadLocalMap中,key被设计成WeakReference弱引用了。

ThreadLocalMap的key设计成弱引用,主要是为了避免内存泄漏的情况。如果 threadlocalmap 的 key 是强引用, 那么只要线程存在, threadlocalmap 就存在, 而 threadlocalmap 结构就是 entry 数组. 即对应的 entry 数组就存在, 而 entry 数组元素的 key 是 threadLocal.即便我们在代码中显式赋值 threadlocal 为 null, 告诉 gc 要垃圾回收该对象. 由于上面的强引用存在, threadlocal 即便赋值为 null, 只要线程存在, threadlocal 并不会被回收。

而设置为弱引用, gc 扫描到时, 发现ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。所以在代码最后都需要用remove把值清空。

remove的源码很简单,找到对应的值全部置空,这样在垃圾回收器回收的时候,会自动把他们回收掉。

并且 threadlocal 的 set get remove 都会判断是否 key 为 null, 如果为 null, 那么 value 的也会移除, 之后会被 gc 回收。

结构大致这样:

ThreadLocalMap存储元素的过程

ThreadLocalMap在存储的时候会给每一个ThreadLocal对象一个threadLocalHashCode,在插入过程中,根据ThreadLocal对象的hash值,定位到table中的位置,如果当前位置是空的,就初始化一个Entry对象放在位置上。如果位置不为空,如果这个Entry对象的key正好是即将设置的key,那么就刷新Entry中的value。如果位置不为空,而且key不等于entry,那就找下一个空位置,直到为空为止。在get的时候,也会根据ThreadLocal对象的hash值,定位到table中的位置,然后判断该位置Entry对象中的key是否和get的key一致,如果不一致,就判断下一个位置。这种方式在不使用链表的情况下,解决了hash冲突。

ThreadLocal实现线程隔离的原理

ThreadLocal实现线程隔离主要是设置值和获取值的时候,就已经保证了它是线程隔离了。
设置值的代码:

public void set(T value) 
    Thread t = Thread.currentThread();// 获取当前线程
    ThreadLocalMap map = getMap(t);// 获取ThreadLocalMap对象
    if (map != null) // 校验对象是否为空
        map.set(this, value); // 不为空set
    else
        createMap(t, value); // 为空创建一个map对象

设置值先是获取当前线程对象,然后从当前线程中获取线程的ThreadLocalMap,判断这个对象是不是空的,如果是空的,就创建一个空的map对象,如果不为空,就重新设值。key就是当前ThreadLocal 的对象,值是添加到这个ThreadLocalMap中的,它是存储在线程内部,然后关联了对应的ThreadLocal。

ThreadLocalMap是当前线程Thread一个叫threadLocals的变量中获取的

ThreadLocalMap getMap(Thread t) 
        return t.threadLocals;
    
public class Thread implements Runnable 
      ……

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
     ……

每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离。

通过ThreadLocal.get 时就能获取到对应的值。

public T get() 
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) 
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) 
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        
    
    return setInitialValue();

AQS

AQS的全称是AbstractQueuedSynchronizer,也就是抽象队列同步器,它是在java.util.concurrent.locks包下的,也就是JUC并发包。java提供了synchronized关键字内置锁,还提供了显示锁,而大部分的显示锁的底层都用到了AQS,比如只有一个线程能执行ReentrantLock独占锁,又比如多个线程可以同时执行共享锁Semaphore、CountDownLatch、ReadWriteLock、CyclicBarrier。

同步器自身没有实现任何同步接口,它仅仅是定义了同步状态获取和释放的方法,提供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态。在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法(getState()、 setState(int newState)和compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。AQS使用模板方法模式,使用者继承AbstractQueuedSynchronizer并重写指定的方法,重写的方法就是对于共享资源state的获取和释放,将AQS在自定义同步组件的实现中,调用它的模板方法,这些模板方法会调用使用者重写的方法,这是模板方法模式很经典的一个运用。

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

同步器拥有首节点和尾节点,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点, 而后继节点将会在获取同步状态成功时将自己设置为首节点,没有成功获取同步状态的线程会成为节点,加入该队列的尾部。获取同步状态成功的线程,因为只有这一个线程能够成功获取到同步状态,所以设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点,并且断开原首节点的next引用就可以了。

独占锁举例

拿ReentrantLock加锁举例,线程调用ReentrantLock的lock()方法进行加锁,这个加锁的过程,用CAS将state值从0变为1。一旦线程加锁成功了之后,就可以设置当前加锁线程是自己。ReentrantLock通过多次执行lock()加锁和unlock()释放锁,对一个锁加多次,从而实现可重入锁,每次线程可重入加锁一次,判断一下当前加锁线程是不是自己,如果是他自己就可以可重入多次加锁,每次加锁,就是把state的值给累加1。

当state=1时代表当前对象锁已经被占用,其他线程来加锁时则会失败,然后再去看加锁线程的变量里面是不是自己之前占用过这把锁,如果不是就说明有其他线程占用了这个锁,失败的线程被放入一个等待队列中,在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,等待已经获得锁的线程,释放锁才能被唤醒。

当它释放锁的时候,将AQS内的state变量的值减1,如果state值为0,就彻底释放锁,会将“加锁线程”变量设置为null。这个时候,会从等待队列的队头唤醒其他线程重新尝试加锁,获得锁成功之后,会把“加锁线程”设置为线程自己,同时线程自己就从等待队列中出队。

底层实现独占锁

public final void acquice(int arg)
	//同步状态获取、节点构造、加入同步队列以及在同步队列中自旋等待
	if(!tryAcquirce(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE),arg))
		selfInterrupt();
	

首先调用自定义同步器实现的tryAcquire(int arg)方法,它可以保证线程安全的获取同步状态,如果同步状态获取成功直接退出返回,如果同步状态获取失败,就构造同步节点,通过addWaiter方法把这个节点加入到同步队列的尾部,由于是(独占式Node.EXCLUSIVE)入参,所以同一时刻只能有一个线程成功获取同步状态。
获取同步状态失败的线程,所以需要通过CAS把前驱节点设置成头节点,接着获取同步状态,获取成功会把当前节点设置成头节点,然后退出返回。
如果获取同步状态失败就阻塞节点中的线程,被阻塞线程的唤醒主要依靠前驱节点的出队或阻塞线程被中断来实现。
最后调用acquireQueued方法,使得该节点以“死循环”的方式获取同步状态。

这个就是aqs实现独占锁的底层实现。

超时获取锁

在Java 5之前,当一个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时这个线程的中断标志位会被修改,但线程依旧会阻塞在synchronized上,等待着获取锁。

Java 5中,在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛出InterruptedException。

后续的版本又进行了优化,提供了超时获取同步状态过程,可以被当作响应中断,是获取同步状态过程的“增强版”, doAcquireNanos方法在支持响应中断的基础上,增加了超时获取的特性。

针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout,为了防止过早通知, nanosTimeout计算公式为:
nanosTimeout = now-lastTime,其中now为当前唤醒时间,lastTime为上次唤醒时间。
如果 nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒, 否则,表示已经超时。
如果nanosTimeout小于等于1000纳秒时, 将不会使该线程进行超时等待,而是进入快速的自旋过程。

原因在于,非常短的超时等待,无法做到十分精确,如果这时再进行超时等待,相反会让nanosTimeout的超时从整体上表现得反而不精确。因此,在超时非常短的场景下,同步器会进入无条件的快速自旋。

共享锁举例

拿CountDownLatch举例,任务分为5个子线程去执行,state也初始化为5。这5个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1,等到所有子线程都执行完后,state=0,会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

共享锁实现原理

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。通过调用同步器的acquireShared方法可以共享式地获取同步状态,只要方法里面的tryAcquireShared方法返回值大于等于0,就可以成功获取到同步状态并退出自旋。对于能够支持多个线程同时访问的并发组件,它和独占式主要区别在于 tryReleaseShared方法必须确保同步状态线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作可能会同时来自多个线程。

线程池

底层运行原理

线程池就是控制运行的线程数量,处理过程中将任务放到队列,然后在线程创建后启动这些任务,如果线程数量超出了最大数量就排队等候,等其他线程执行完毕再从队列中取出任务执行。

线程池相当于银行网点,常驻核心数相当于今日当值窗口,线程池能够同时执行的最大线程数相当于银行所有的窗口,任务队列相当于银行的候客区,当今日当值窗口满了,多出来的客户去候客区等待,当候客区满了,银行加开窗口,候客区先来的客户去加班窗口,当银行所有的窗口满了,其他客户在候客区等待,同时拒绝其他客户进入银行。当用户少了,加班的窗口等待时间(相当于多余线程存活的时间)(等待时间的单位相当于unit参数)假设超过一个小时还是没有人来,就取消加班的窗口。

七大核心参

以上是关于35kJava开发岗:基础篇的主要内容,如果未能解决你的问题,请参考以下文章

35kJava开发岗:Redis篇

35kJava开发岗:Redis篇

35kJava开发岗:MQ篇

35kJava开发岗:MQ篇

Java开发岗:项目篇

Java开发岗:项目篇