知识屋:更实用的电脑技术知识网站
所在位置:首页 > 科技  > 软件

2022Java开发面试题(基础篇)

发表时间:2022-03-24来源:网络

一、数据结构

1.排序二叉树

搜索二叉树特性:左子节点

红黑树有两种方式保持平衡:旋转和染色

旋转:旋转分为两种,左旋和右旋

●优点:红黑树是一种平衡二叉树,跟二叉树相比树的高度得到了降低

●缺点:红黑树虽然可以重平衡,但是它是一个二叉树,避免不了大数据情况下树的高度很高的问题。

3.B树(多叉树)

●优点:B树是一种多叉树,它的每个节点块的大小为16KB,每个节点块中间可以包含多个节点,相比红黑树,大大降低了树的高度。

●缺点:每个节点块只有16KB(该数值为mysql的默认数值,此时磁盘IO读取一个节点块最块),B树的每个节点块中的节点即包含索引,又包含数据行,这样节点所占的空间会变大,所以导致每个节点块中可以存储节点会变少,然后导致树的高度变高。

4.B+树(多叉树)

B+树特性

1、非叶子节点不存储data,只存储索引,非叶子节点块可以存储更多的索引,树的高度更低

2、叶子节点包含所有索引字段

3、叶子节点块之间中双向指针连接,提高范围查找的能力

B+树是B树的优化版本

●优点:

○相比B树来说,B+树的非叶子节点只存储索引,叶子节点存储索引和主键KEY或者数据行。这样的话,相同的节点块(16KB)可以存储更多的节点,树的高度会大大降低。B+树的高度在3层左右就可以存储千万条的数据行,此时磁盘IO只需要3次。

○B+树叶子节点块之间有双向指针将叶子节点连接起来了,方便范围查询。

二、集合

1.ArrayList & LinkedList

1、ArrayList的实现是基于数组,LinkedList的实现是基于双向链表。
2、对于随机访问,ArrayList优于LinkedList
3、对于插入和删除操作,LinkedList优于ArrayList
4、LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

2.HashMap

1、数据结构:

JDK1.7的数据结构是数组+链表,JDK1.8的数据结构是数组+链表+红黑树

其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。

数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素如果链表长度>8&数组大小>=64,链表转为红黑树如果红黑树节点个数>> 16);

b.判断tab是否位空或者长度为0,如果是则进行扩容操作。

c.根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖。tab[i = (n - 1) & hash])

d.判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。

e.如果链表中插入节点的时候,链表长度大于等于8,则需要把链表转换为红黑树。treeifyBin(tab, hash);

f.最后所有元素处理完成后,判断是否超过阈值;threshold,超过则扩容。

3、HashMap查找元素

使用扰动函数,获取新的哈希值计算数组下标,获取节点当前节点和key匹配,直接返回否则,当前节点是否为树节点,查找红黑树否则,遍历链表查找

4、哈希/扰动函数

HashMap的哈希函数是先拿到 key 的hashcode,是一个32位的int类型的数值,然后让hashcode的高16位和低16位进行异或操作,可以降低哈希碰撞的概率。

HashMap 的数组长度要取 2 的整数幂。因为这样(数组长度 - 1)正好相当于一个 “低位掩码”。与操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。

右移 16 位,正好是 32bit 的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

5、为什么HashMap的容量是2的倍数

a.第一个原因是为了方便哈希取余

b.第二个方面是在扩容时,利用扩容后的大小也是2的倍数,将已经产生hash碰撞的元素完美的转移到新的table中去

6、为什么扩容因子是0.75

假如我们设的比较大,元素比较多,空位比较少的时候才扩容,那么发生哈希冲突的概率就增加了,查找的时间成本就增加了。
我们设的比较小的话,元素比较少,空位比较多的时候就扩容了,发生哈希碰撞的概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了。

7、JDK1.8对HashMap主要做了哪些优化

数据结构:数组 + 链表改成了数组 + 链表或红黑树
原因:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)降为O(logn)

链表插入方式:链表的插入方式从头插法改成了尾插法
简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。
原因:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。

扩容rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。
原因:提高扩容的效率,更快地扩容。

扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;
散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。
原因:做 4 次的话,边际效用也不大,改为一次,提升效率。

3.ConcurrentHashMap

1、JDK1.7,底层结构是散列表(数组+链表),采用了分段锁技术,其中 Segment 继承于 ReentrantLock,内部HashEntry用volatile修饰,保证可见性和有序性。put操作首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用 scanAndLockForPut() 自旋获取锁,流程如下:

尝试自旋获取锁。如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

2、JDK1.8,底层结构是散列表(数组+链表)+红黑树,其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

put操作的还是比较复杂的,大致可以分为以下步骤:

根据 key 计算出 hashcode 。判断是否需要进行初始化。为当前 key 定位出的 Node,若为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。如果都不满足,则利用 synchronized 锁写入数据。如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。

三、JVM

1.运行时数据区

1、程序计数器:线程私有,是一块较小的内存空间,存储方法执行的指令地址,唯一不会发生OOM的内存区域

2、Java虚拟机栈:线程创建时,会创建一个栈帧。栈帧存储局部变量表、操作数栈、动态链接、方法的返回地址、其它附加信息。

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。

3、本地方法栈:类似于Java虚拟机栈,针对native方法

4、Java堆:存放对象实例

5、方法区:存放经过JVM加载后的类信息、常量、静态变量,即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

OOM异常:

Java堆溢出

虚拟机栈和本地方法栈溢出

方法区溢出

对象内存:

在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象访问方式:

使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

2.对象的创建

TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区(线程私有分配区,私有分配,公共查看),占用 Eden 区(缺省 Eden 的1%),默认开启,JVM 会为每一个线程分配一块 TLAB 区域,避免堆对象共享造成的多线程线程同步。

CMS收集器:

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于“标记—清除”算法实现。

过程:

初始标记(CMS initial mark)

并发标记(CMS concurrent mark)

重新标记(CMS remark)

并发清除(CMS concurrent sweep)

G1收集器:

4大特点:

并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

4个过程:

初始标记(Initial Marking)

并发标记(Concurrent Marking)

最终标记(Final Marking)

筛选回收(Live Data Counting and Evacuation)

4、内存分配与回收策略

1.对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

2.大对象直接进入老年代

大对象是指,需要大量连续内存空间的Java对象

3.长期存活的对象将进入老年代,默认是熬过15次Minor GC

4.空间分配担保原则:

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

4.类加载机制

类的生命周期

加载、连接(验证、准备、解析)、初始化、使用、卸载

双亲委派模型

显示虚拟机进程以及进程的配置、环境信息(jps、jinfo)。

监视应用程序的CPU、GC、堆、方法区以及线程的信息(jstat、jstack)。

dump以及分析堆转储快照(jmap、jhat)。

方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法。

离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照,可以将快照发送开发者处进行Bug反馈。

JVM调优的步骤

一般情况下,JVM调优可通过以下步骤进行:

分析GC日志及dump文件,判断是否需要优化,确定瓶颈问题点;确定JVM调优量化目标;确定JVM调优参数(根据历史JVM参数来调整);依次调优内存、延迟、吞吐量等指标;对比观察调优前后的差异;不断的分析和调整,直到找到合适的JVM参数配置;找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。

以上操作步骤中,某些步骤是需要多次不断迭代完成的。一般是从满足程序的内存使用需求开始的,之后是时间延迟的要求,最后才是吞吐量的要求,要基于这个步骤来不断优化,每一个步骤都是进行下一步的基础,不可逆行之。

JVM参数

JVM调优最重要的工具就是JVM参数了。先来了解一下JVM参数相关内容。

-XX 参数被称为不稳定参数,此类参数的设置很容易引起JVM 性能上的差异,使JVM存在极大的不稳定性。如果此类参数设置合理将大大提高JVM的性能及稳定性。

不稳定参数语法规则包含以下内容。

布尔类型参数值:

-XX:+ '+'表示启用该选项-XX:- '-'表示关闭该选项

数字类型参数值:

-XX:=

给选项设置一个数字类型值,可跟随单位,例如:'m'或'M'表示兆字节;'k'或'K'千字节;'g'或'G'千兆字节。32K与32768是相同大小的。

字符串类型参数值:

-XX:=
给选项设置一个字符串类型值,通常用于指定一个文件、路径或一系列命令列表。例如:-XX:HeapDumpPath=./dump.core

JVM参数解析及调优

比如以下参数示例:

-Xmx4g Xms4g Xmn1200m Xss512k -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:PermSize=100m -XX:MaxPermSize=256m -XX:MaxTenuringThreshold=15

上面为Java7及以前版本的示例,在Java8中永久代的参数-XX:PermSize和-XX:MaxPermSize已经失效。这在前面章节中已经讲到。

参数解析:

-Xmx4g:堆内存最大值为4GB。-Xms4g:初始化堆内存大小为4GB。-Xmn1200m:设置年轻代大小为1200MB。增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。-Xss512k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1MB,以前每个线程堆栈大小为256K。应根据应用线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5-XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值。设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10-XX:PermSize=100m:初始化永久代大小为100MB。-XX:MaxPermSize=256m:设置持久代大小为256MB。-XX:MaxTenuringThreshold=15:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

新生代、老生代、永久代的参数,如果不进行指定,虚拟机会自动选择合适的值,同时也会基于系统的开销自动调整。

可调优参数:

-Xms:初始化堆内存大小,默认为物理内存的1/64(小于1GB)。

-Xmx:堆内存最大值。默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制。

-Xmn:新生代大小,包括Eden区与2个Survivor区。

-XX:SurvivorRatio=1:Eden区与一个Survivor区比值为1:1。

-XX:MaxDirectMemorySize=1G:直接内存。报java.lang.OutOfMemoryError: Direct buffer memory异常可以上调这个值。

-XX:+DisableExplicitGC:禁止运行期显式地调用System.gc()来触发fulll GC。

注意: Java RMI的定时GC触发机制可通过配置-Dsun.rmi.dgc.server.gcInterval=86400来控制触发的时间。

-XX:CMSInitiatingOccupancyFraction=60:老年代内存回收阈值,默认值为68。

-XX:ConcGCThreads=4:CMS垃圾回收器并行线程线,推荐值为CPU核心数。

-XX:ParallelGCThreads=8:新生代并行收集器的线程数。

-XX:MaxTenuringThreshold=10:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

-XX:CMSFullGCsBeforeCompaction=4:指定进行多少次fullGC之后,进行tenured区 内存空间压缩。

-XX:CMSMaxAbortablePrecleanTime=500:当abortable-preclean预清理阶段执行达到这个时间时就会结束。

在设置的时候,如果关注性能开销的话,应尽量把永久代的初始值与最大值设置为同一值,因为永久代的大小调整需要进行FullGC才能实现。

6.JMM(Java内存模型)

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如上图所示。

四、并发编程

1.并发底层原理

并发编程中有3大重要特性:原子性、可见性、有序性

原子性:即一个操作或者多个操作,要么全部执行并且不被打断,要么就都不执行。

可见性:多个线程共同访问共享变量时,某个线程修改了此变量,其他线程能立即看到修改后的值。

有序性:程序执行的顺序按照代码的先后顺序执行。(由于JMM模型中允许编译器和处理器为了效率,进行指令重排序的优化。指令重排序在单线程内表现为串行语义,在多线程中会表现为无序。那么多线程并发编程中,就要考虑如何在多线程环境下可以允许部分指令重排,又要保证有序性)

volatile

volatile可以保证可见性和有序性,无法保证原子性。

保证可见性的语义:

a.当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存;

b.写操作会导致其他线程中的缓存无效;

保证有序性:volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序。

synchronized

synchronized关键字同时保证上述三种特性。

synchronized是同步锁,同步块内的代码相当于同一时刻单线程执行,故不存在原子性和指令重排序的问题

synchronized关键字的语义JMM有两个规定,保证其实现内存可见性:

a.线程解锁前,必须把共享变量的最新值刷新到主内存中;

b.线程加锁前,将清空工作内存中共享变量的值,从主内存中重新取值。

2.线程和线程通信

线程是CPU分配的基本单位。

线程通信主要涉及两个动作:通知和等待。因此在Java的Object类中自带了实现通知和等待的方法。

方法名wait()表示线程一直等待,知道其他线程通知,与sleep不同,会释放锁wait(long timeout)指定等待的毫秒数notify()唤醒一个处于等待状态的线程notifyAll()唤醒同一个对象上所有调用wait()方法的线程,优先级别高的线程优先调度

3.锁

在并发编程中,锁是一种非常重要的机制,Java提供了种类丰富的锁,每种锁因其特性不同,在适当的场景下能够展现出非常高的效率,下面针对不同的特性,对锁和相关概念进行分类介绍。

公平锁和非公平锁

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。
公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁。

自旋锁和阻塞锁

自旋锁:是指当线程获取锁失败的时候,去执行一个无意义的循环,循环结束后再重新去竞争锁,如果竞争不到则继续循环。整个过程中线程一直处于运行(running)状态。
阻塞锁:和自旋锁相对,指当线程获取锁失败时,线程进入阻塞(blocking)状态,当获取相应的信号时(唤醒,时间),进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。
自旋锁的出现原因:线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。而且,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了自旋锁。
适用情况:自旋等待不能代替阻塞。自旋等待本身虽然,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋当代的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。

可重入锁和不可重入锁

可重入锁:指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。即获取锁的粒度是线程而不是调用。
不可重入锁:和可重入锁相反,指的是同一线程外层函数获得锁之后,不能再次获取该锁。
可重入锁最大的作用是避免死锁。
在JAVA环境下 ReentrantLock 和synchronized 都是可重入锁。

类锁和对象锁

在Java程序运行时环境中,JVM需要对两类线程共享的数据进行协调:
1)保存在堆中的实例变量
2)保存在方法区中的类变量
为了实现监视器的排他性监视能力,java虚拟机为每一个对象和类都关联一个锁。代表任何时候只允许一个线程拥有的特权。线程访问实例变量或者类变量不需锁。
类锁实际上用对象锁来实现。当虚拟机装载一个class文件的时候,它就会创建一个java.lang.Class类的实例。当锁住一个类的时候,实际上锁住的是那个类的Class对象。
事实上,synchronized修饰非静态方法、同步代码块的synchronized (this)用法和synchronized (非this对象)的用法锁的是对象,线程想要执行对应同步代码,需要获得对象锁。
synchronized修饰静态方法以及同步代码块的synchronized (类.class)用法锁的是类,线程想要执行对应同步代码,需要获得类锁。

悲观锁和乐观锁

悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
乐观锁:假定不会发生并发冲突,只在提交操作时检测是否违反数据完整性。(使用版本号或者时间戳来配合实现)

共享锁和排它锁

共享锁:如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。
排它锁:如果事务T对数据A加上排它锁后,则其他事务不能再对A加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。

读写锁

读写锁是一个资源能够被多个读线程访问,或者被一个写线程访问但不能同时存在读线程。Java当中的读写锁通过ReentrantReadWriteLock实现。

互斥锁

所谓互斥锁就是指一次最多只能有一个线程持有的锁。在JDK中synchronized和JUC的Lock就是互斥锁。

偏向锁、轻量级锁和重量级锁

synchronized的锁可以分为偏向锁、轻量级锁以及重量级锁,通过Java对象头实现。
偏向锁是JDK6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。
轻量级锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。
重量级锁:在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

整个synchronized锁升级流程如下:

检测对象头的Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志为1如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁如果自旋成功则依然处于轻量级状态如果自旋失败,则升级为重量级锁

4.JUC

AQS(Abstract)

AQS,Abstract,即队列同步器。它是构建锁或者其他同步组件的基础框架(如ReentrantLock、ReentrantReadWriteLock、Semaphore等),JUC并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。它是JUC并发包中的核心基础组件。

原理:AQS说是一个同步器框架,但它其实就是一个Java类:Abstract,它是抽象类。我们继承它并实现几个方法,就可以得到一个功能完好的同步器。在java的JUC包中,许多同步器都是基于AQS来实现的。如ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore等类都是使用AQS来实现同步操作的。另外,java线程池的Worker实现中,也用到了AQS。 AQS的原理不是很难,它维护了一个状态state以及一个CLH队列。在不同的业务场景下,state也可以理解为资源,CLH队列是一个双端队列,每个节点存放了正在等待获取资源的线程。假设现在有线程通过调用acquire()方法尝试获取资源(state),如果资源不够,AQS就会将这个线程封装成一个Node插入到CLH队列的尾部,然后这个线程进入休眠,等待被唤醒。如果一个又一个线程执行acquire()并且资源不足,在CLH队列排队的线程就会越来越多。 之后如果有其他线程执行release()释放一部分资源。AQS在释放完资源就会唤醒CLH队列头部的那个线程,所以CLH队列是一个先到先服务的队列。队列中的线程被唤醒后,就会检查资源是否满足自己的要求,如果够了,就立刻返回,不然的话继续阻塞。 源码:看AQS源码我们一般关注4个方法,acquire(int arg)、ac(int arg)、release(int arg)、releaseShared(int arg)。这里不再详细赘述。

ConcurrentHashMap

详见上面集合部分2.3

CopyOnWriteArrayList

CopyOnWriteArrayList通过增加写时复制语义来避免并发访问引起的问题,也就是说任何修改操作都会在底层创建一个列表的副本,也就意味着之前已有的迭代器不会碰到意料之外的修改。这种方式对于不要严格读写同步的场景非常有用,因为它提供了更好的性能。记住,要尽量减少锁的使用,因为那势必带来性能的下降(对数据库中数据的并发访问不也是如此吗?如果可以的话就应该放弃悲观锁而使用乐观锁),CopyOnWriteArrayList很明显也是通过牺牲空间获得了时间(在计算机的世界里,时间和空间通常是不可调和的矛盾,可以牺牲空间来提升效率获得时间,当然也可以通过牺牲时间来减少对空间的使用)。

5.原子类 & 并发工具类

1、CAS:

比较并交换(compare and swap, CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。

ABA问题:就是说来了一个线程把值A改成了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。

ABA解法:用版本号(或者时间戳)去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。

2、原子类:

在java.util.concurrent.atomic包下,atomic包里面一共提供了13个类,分为4种类型,分别是:原子更新基本类型,原子更新数组,原子更新引用,原子更新属性。原子类也是java实现同步的一套解决方案。

既然已经有了synchronized关键字和lock,为什么还要引入原子类呢?或者什么场景下使用原子类更好呢?

在很多时候,我们需要的仅仅是一个简单的、高效的、线程安全的递增或者递减方案,这个方案一般需要满足以下要求:

1、 简单:操作简单,底层实现简单

2、 高效:占用资源少,操作速度快

3、 安全:在高并发和多线程环境下要保证数据的正确性

对于是需要简单的递增或者递减的需求场景,使用synchronized关键字和lock固然可以实现,但代码写的会略显冗余,且性能会有影响,此时用原子类更加方便。

原理:

Atomic包的类的实现绝大调用Unsafe的方法,而Unsafe底层实际上是调用C代码,C代码调用汇编,最后生成出一条CPU指令cmpxchg,完成操作。这也就为啥CAS是原子性的,因为它是一条CPU指令,不会被打断。

3、CountDownLatch

CountDownLatch可以使一个获多个线程等待其他线程各自执行完毕后再执行。

CountDownLatch 定义了一个计数器,和一个阻塞队列, 当计数器的值递减为0之前,阻塞队列里面的线程处于挂起状态,当计数器递减到0时会唤醒阻塞队列所有线程,这里的计数器是一个标志,可以表示一个任务一个线程,也可以表示一个倒计时器,CountDownLatch可以解决那些一个或者多个线程在执行之前必须依赖于某些必要的前提业务先执行的场景。

CountDownLatch实现原理:

1.创建计数器

当我们调用CountDownLatch countDownLatch=new CountDownLatch(4) 时候,此时会创建一个AQS的同步队列,并把创建CountDownLatch 传进来的计数器赋值给AQS队列的 state,所以state的值也代表CountDownLatch所剩余的计数次数;

2.阻塞线程

当我们调用countDownLatch.wait()的时候,会创建一个节点,加入到AQS阻塞队列,并同时把当前线程挂起。

3.计数器递减

当我们调用countDownLatch.down()方法的时候,会对计数器进行减1操作,AQS内部是通过释放锁的方式,对state进行减1操作,当state=0的时候证明计数器已经递减完毕,此时会将AQS阻塞队列里的节点线程全部唤醒。

4、CyclicBarrier

CyclicBarrier也是一个同步辅助类,它允许一组线程相互等待,直到到达某个公共屏障点(common barrier point)。通过它可以完成多个线程之间相互等待,只有当每个线程都准备就绪后,才能各自继续往下执行后面的操作。

类似于CountDownLatch,它也是通过计数器来实现的。当某个线程调用await方法时,该线程进入等待状态,且计数器加1,当计数器的值达到设置的初始值时,所有因调用await进入等待状态的线程被唤醒,继续执行后续操作。

CyclicBarrier在释放等待线程后可以重用,所以称为循环barrier。

CountDownLatch与CyclicBarrier的区别

CountDownLatch主要是实现了1个或N个线程需要等待其他线程完成某项操作之后才能继续往下执行操作,描述的是1个线程或N个线程等待其他线程的关系。CyclicBarrier主要是实现了多个线程之间相互等待,直到所有的线程都满足了条件之后各自才能继续执行后续的操作,描述的多个线程内部相互等待的关系。CountDownLatch是一次性的,而CyclicBarrier则可以被重置而重复使用。

5、Semaphore

Semaphore与CountDownLatch相似,不同的地方在于Semaphore的值被获取到后是可以释放的,并不像CountDownLatch那样一直减到底。它也被更多地用来限制流量,类似阀门的功能。

如果限定某些资源最多有N个线程可以访问,那么超过N个主不允许再有线程来访问,同时当现有线程结束后,就会释放,然后允许新的线程进来。有点类似于锁的lock与unlock过程。相对来说他也有两个主要的方法:

用于获取权限的acquire(),其底层实现与CountDownLatch.countdown()类似;用于释放权限的release(),其底层实现与acquire()是一个互逆的过程。

6.线程池及Executor框架

6.1 线程池是什么

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。

以下线程池指的是JDK中提供的ThreadPoolExecutor类。

当然,使用线程池可以带来一系列好处:

降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。提高响应速度:任务到达时,无需等待线程创建即可立即执行。提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。

6.2 线程池解决的问题是什么

线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:

频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。系统无法合理管理内部的资源分布,会降低系统的稳定性。

为解决资源分配这个问题,线程池采用了“池化”(Pooling)思想。池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。

6.3 总体设计

Java中的线程池核心实现类是ThreadPoolExecutor,本节基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。

其生命周期转换如下入所示:

6.6 任务执行机制

任务调度

任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:

首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

任务缓冲

任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

任务申请

由上文的任务分配部分可知,任务的执行有两种可能:一种是任务直接由新创建的线程执行。另一种是线程从任务队列中获取任务然后执行,执行完任务的空闲线程会再次去从队列中申请任务再去执行。第一种情况仅出现在线程初始创建的时候,第二种是线程获取任务绝大多数的情况。

线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。这部分策略由getTask方法实现,其执行流程如下图所示:

任务拒绝

任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。

拒绝策略是一个接口,其设计如下:

public interface RejectedExecutionHandler { void rejectedExecution(Runnable r, ThreadPoolExecutor executor); }

用户可以通过实现这个接口去定制拒绝策略,也可以选择JDK提供的四种已有拒绝策略,其特点如下:

6.7 Worker线程管理

Worker线程

Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。

Worker执行任务的模型如下图所示:

线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。

Worker是通过继承AQS,使用AQS来实现独占锁这个功能。没有使用可重入锁ReentrantLock,而是使用AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。

lock方法一旦获取了独占锁,表示当前线程正在执行任务中。如果正在执行任务,则不应该中断线程。如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。

Worker线程增加

增加线程是通过线程池中的addWorker方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。addWorker方法有两个参数:firstTask、core。firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize,其执行流程如下图所示:

Worker线程回收

线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。

try { while (task != null || (task = getTask()) != null) { //执行任务 } } finally { processWorkerExit(w, completedAbruptly);//获取不到任务时,主动回收自己 }

线程回收的工作是在processWorkerExit方法完成的。

事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。

Worker线程执行任务

在Worker类中的run方法调用了runWorker方法来执行任务,runWorker方法的执行过程如下:

while循环不断地通过getTask()方法获取任务。getTask()方法从阻塞队列中取任务。如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。执行任务。如果getTask结果为null则跳出循环,执行processWorkerExit()方法,销毁线程。

执行流程如下图所示:

收藏
  • 人气文章
  • 最新文章
  • 下载排行榜
  • 热门排行榜