该知识分为两部分,(上)部分为Java技术体系,技术篇。(下)部分为Java基础知识,面试篇。
本文更多的写的是我已经学过的知识点,我也明白,有的知识点依然太浅了。但学习嘛,就是不断完善自我的过程。还是那句话本文只适合收藏起来整理思路,梳理知识点,不适合当作具体的知识点参考学习。当然,如果你想学习具体的细节,欢迎查看我的其他博客,本文的知识点也大多是来自于我已经发布的博客。希望对你有帮助
1、自我介绍
面试官你好,我叫默辨…(一定要突出自己的亮点,不要赘述简历上的信息)
2、设计模式
1、桥接模式
首先我们需要对目标建立两个维度(小米手机:小米 + 手机 = 品牌 + 产品),一个用于横坐标,一个用于纵坐标。对于前面的例子我们完全可以使我们的最终目标类继承小米类,再继承产品类,但这不便于扩展且不利于维护。我们引入桥接模式以后,只需要维护对应的两个维度即可,使他们能够更好的组合在一起,同样能够达到我们对应的目标。从系统的设计上来讲,提高了系统的可扩展性。**在使用桥接模式时,需要确定好我们需要选择的是那一个坐标轴,这样更有利于我们的系统设计。**使用场景:Java语言用Java虚拟机实现了平台的无关性、JDBC驱动程序也是桥接模式的应用之一
2、适配器模式
明确三个对象,中间的适配器对象,以及两边的两个目标对象。当两个目标需要连接的时候,如果无法直接连接,我们就需要引入一个适配器对象。比如电脑和网线之间需要适配器,原本网线无法直接连接电脑,但是网线能够上网的功能是目标,电脑的插槽是另一个目标,两个目标是无法直接连接的。如果我们现在能将两者的功能放到一起,组成一个适配器。那么问题就可以解决了。(适配器模式的妙用,可以用来解决桥接模式中两个维度不好建立的问题)
3、建造者模式
首先我们要明确四个对象。第一个是我们具体的产品(Product),第二个是我们具体产品的抽象功能(Builder),第三个是我们具体产品抽象功能的具体实现(Worker),第四个是具体的指挥者(Director)。指挥者指挥我们产品的最终组合形态,产品是抽象功能的组合结果,前者侧重调用组合方式,后者侧重最终结果,正是因为前者的调用,才能形成后者的具体产品。我们要明确的就是,产品本身是由功能组合而成,且这个功能的成功实现是包含两部分的,Product和Worker。最终我们只需要依靠指挥者就能够得到我们想要的产品了,且产品形式多样,其中的实现细节不需要关心。
4、代理模式
分为静态代理和动态代理,区别在于代理类的实现方式不同(后者是动态生成的)。代理模式主要分为四个部分,抽象的功能,真实的角色,代理的角色,和代理角色的其他操作。抽象功能为真实角色的功能,并且代理角色由于其代理的特性也会拥有该功能,但是其代理的特点就在于它还可以自己添加我们的其他操作。对于静态代理模式而言,我们的代理对象是写死的,这种不利于我们后期的扩展,所以引入了动态代理模式,动态代理模式将代理角色处理为了一个模板角色,始得代理角色的生成会根据我们传入参数的不同而结果不同。在实际的生产中,如果我们想要给已存在的代码添加新的功能,那么我们就添加一个代理角色(对目标功能进行内部实现)、新添加的功能(代理角色功能),然后我们直接调用代理角色就可以实现功能了。
5、原型模式
实现Cloneable接口,重写对应得clone方法。用于快速的创建一个与此刻相同的对象。原型模式可以理解为克隆一个对象,但是存在深克隆和浅克隆。如果我们克隆出现的对象只会与当前的对象一模一样,之后的变化不再影响,那么我们就将这种克隆模式成为深克隆。如果我们后面发现,只是基本数据类型的变量没有边,引用类型的变量跟着还在变得话,那么我们就称之为浅克隆。对于浅克隆变为深克隆,我们只需要在对应得clone方法中,添加对应得逻辑判断即可(如果对象存在引用变量,在clone方法中添加一个获取当前类中引用变量得条件,直接使用之前得条件进行赋值,再返回即可)。
6、工厂模式
工厂模式分为简单的工厂方法模式和工厂方法模式。传统的创建一个实例对象为单独的去new,使用简单工厂模式以后,我们只需要new对应的工厂就可以了,继而根据不同的参数创造出我们想要的不同的对象。由于简单的工厂方法模式会修改我们的工厂代码,不符合OOP的开闭原则,所以引入了我们的工厂方法模式。工厂模式为每一个目标类都创建了一个对应的工厂,当然我们的xxFactory接口内部的方法要设置为返回一个Car类型的对象,我们的每一个目标工厂都是实现xxFactory接口,每一个目标都去实现xx接口。在我们的测试类中,我们使用工厂里面的getxx方法,就会返回具体的目标类了。
7、抽线工厂模式
在工厂方法模式的基础上,由于我们工厂类的职责过于单一,于是对工厂方法模式进行了改良。它将我们的Factory接口进行了改进,功能由原来单一的getxx方法变为了我们更加丰富的具体的功能,功能更多了。换句话说就是,抽线工厂模式由原来的只能获取单一工厂的功能变成了能获取好几个功能,即整体的扩展性更强了。(当然我们也会发现,如果我们需要新增产品,我们就需要去我们的工厂接口中添加对应的接口类,违背了开闭原则)
8、单例模式
可以主要分为两大类,懒汉式和饿汉式(本质是类加载的初始化和实例化的区别)。饿汉式第一种,直接一个静态变量接收私实例化的私有构造函数;饿汉式第二种,使用一个静态代码块来初始化我们私有的构造器;饿汉式第三种,当然我们还可以使用枚举类型来创建单例模式,优势在于枚举类型不会被反射破坏内部结构;懒汉式第一种,使用静态方法来实例化我们的私有构造器;懒汉式第二种,我们在懒汉式第一种的基础上直接添加一个锁,简单干脆,但是效率不是最优;懒汉式第三种,配合volatile 关键字,在锁的外面和里面分别添加一个判断,volatile关键字可以防止指令重排,此时效率能够得到改善;懒汉式第六种,使用内部类,由于内部类的加载和初始化都是线程安全的的特性,所以该方式也就是线程安全的
9、GOF23分类
-
创建型
- Factory Method(工厂方法)
- Abstract Factory(抽象工厂)
- Builder(建造者)
- Prototype(原型)
- Singleton(单例)
-
结构型
- Adapter Class/Object(适配器)
- Bridge(桥接)
- Composite(组合)
- Decorator(装饰)
- Facade(外观)
- Flyweight(享元)
- Proxy(代理)
-
行为型
- Interpreter(解释器)
- Template Method(模板方法)
- Chain of Responsibility(责任链)
- Command(命令)
- Iterator(迭代器)
- Mediator(中介者)
- Memento(备忘录)
- Observer(观察者)
- State(状态)
- Strategy(策略)
- Visitor(访问者)
3、排序算法
首先我们需要两层循环,外层循环遍历数据,内层循环用来排序数据,每一次内层的循环结束都能得到我们的最大的一个数字,这样我们就可以每一次循环的次数都依次降低(从小到大的排序)。排序的核心思想就是数组中的数据依次比较,如果数组前面的数据大于后面的数据那就交换位置。该算法可以优化的地方就是,如果一次内层循环下来,没有出现交换顺序的情况,说明我们的数组已经是有序的,可以提前终止(内部使用一个标志位来判定)。
还是拥有两层循环,外层循环遍历数据,内层循环用于交换数据,每一次内层循环的结束我们都能获取到最小的数据,然后让它和头部的数据交换位置(从小到大排序)。该排序算法的要点为,我们需要定义一个最小的数据,以及最小数字对应的索引位置。每一次在剩下的数据中获取到最小值后都与队首数据进行交换(将最小的数字的索引位置与开始遍历时的索引位置换一下就可以完成数据的交换)。选择排序,重在选择,选择剩下数据中的数据进行排序,即将数据分为两部分,一部分有序,一部分无序。在无序的一部分数据中选择出适当的数据放入到有序的数据里。
同样也是两层循环,外层循环用来遍历数据,内层循环(使用while循环操作)用来将数据进行插入。该方法将数据分为三堆,第一堆是我们已经排好序的数字,第二堆是我们手上拿的数字,第三堆是还没有排序的数字。我们手中的数字会出现(n-1)次,所以我们的外层循环为(n-1)次。每一次循环的目的是为了将我们手中的数字放到第一堆中恰当的位置,那么就需要与前一堆数字依次进行比较,如果数字比较成功(前一个数字比后一个大),那么我们就将成功的数字往后移动(arr[index+1] == arr[index]),即可完成插入排序。该排序抓住插入一词,将我们的数字插入到一个已经排好顺序的数组中,注意要和选择排序区分开
希尔排序分为交换法(冒泡排序)和移动法(插入排序)。希尔排序的本质为在冒泡排序和插入排序的基础上套上一个外衣,使得我们之前的内层循环次数变少。他的这个外衣的含义为缩小增量,把所有数据按下标进行分组,对每组使用插入排序或冒泡排序,随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰被分成一组,算法便终止。该算法只是对冒泡排序和插入排序的一个优化。
该算法的思想比较简单,但是在代码实现上有一定的困难(起码对我来说是的,我个人比较讨厌出现递归算法)。它将我们的数据也是分为三份,中间的基准值为一份,左边和右边分别为单独的一份。该算法不强调我们数据的有序,只需要做到和基准值进行比较时,我们左右两边的两份数据能够在自己对应的位置就行了,然后左右两边分别递归,在递归中同样是与中间的基准值进行比较,最终我们的数据就能达到顺序排列的效果。
其本质是使用了分治算法的特点,分而治之。他将我们的数据递归的进行分开,使得我们数据最终只有单独地一个;然后再进行组合,在组合地这个过程中,完成数据的有序组合。当然该排序算法中也涉及了递归操作,思路比较简单,代码还需要多加练习。
该排序算法与前面的算法实现逻辑上有很大的区别。实现该算法我们需要定义一个二维数组,用来存放我们的数据,大小为10 * 数据总数。实现思路是每一次循环,会将我们的数据根据个位(十位、百位…)的大小,分别放入到不同的位置上,个位数是1,就放在二维数组的1号位置,以此类推。直到循环完我们数据中最大数字的位数时,整个排序结束。该算法的比较次数是根据数据的最大值的位数来决定的,虽然效率极高,但是也是十分耗费空间资源的。这也是一个典型的用空间换时间的排序方法。
4、集合类源码
1、HashMap
-
基本数据结构:在JDK1.7之前,其结构是数组+链表,以数组为地基,在每一个哈希桶位上建立与key的hash结果相同的链表。在JDK1.8及以后,其结构变为了数组+链表+红黑树,其他的结构都没有太大的变化,主要的区别在于,当链表的数据达到8的时候,之前的链表结构就会变为红黑树结构,用于提高集合的查询效率,因为链表的查找效率为O(n),而红黑树的查找效率为O(logn)。
-
HashMap在添加数据时的过程:我们在没有指定初始大小时,会使用HashMap默认的16作为初始容量(但是我们在真正存储数据的时候只能存储16*0.75个,即这里还要涉及一个加载因子的变量)。在数据的存储上:我们添加第一个key-value的键值对时,我们的key会经过一个无符号右移16位异或运算得到一个具体的值,然后存放到对应的位置。第二个key-value添加时,会再次对key进行hash运算,如果结果与之前的结果有冲突,则会调用对应的equls方法进行内容的比对,如果内容相同,则覆盖之前的内容,如果内容不同则放置在与之key的hash结果相同的位置上的链表末尾或者树节点上。在扩容上:首先要明确的是集合的容量不大于64,我们这里使用该集合的默认容量,当我们集合某个槽位置上的数据量达到了8个,那么我们的集合会进行一个扩容操作,使得我们的容量变为了32,再达到8个再扩容,如果已经达到了64,集合链表上的数据再次达到8个,那么我们链表就会转换为红黑树结构,提高该集合的查找效率
-
阈值为何设置为8:在源代码的注释中有提及,在强大的hash算法之下,我们已经尽可能的将数据均匀地分散开了,如果数据量还是能达到8,说明数据量确实有点大,此时转换为红黑树效率更高,当然在数学家给出的泊松分布上,更能说明其科学性,即8是数学家的出来的,概率为亿分之六。
-
HashMap的相关默认值是多少:默认的加载因子0.75,默认的集合容量16,链表数据量超过8变为红黑树,数据量变为6时才会回复为链表
-
数组的长度为什么一定要是2的幂次方:我们在大学课本里面讲到的算法是hash%length,这里我们的算法结果和他相同,但是具体的实现过程有一定的优化,效率更高,使用的是hash&(length-1)。使用该算法的一个前提就是,我们的length一定要是2的幂次方数才有效,所以如果我们传入的长度不是2的幂次方数,集合内部依然会帮我们转换为2的幂次方数。
-
引入红黑树的优势:链表遍历时的时间复杂度为O(n),引入红黑树以后的时间复杂度就变为了O(logn)。当数据越来越多时,能够提高效率。
2、ArrayList
-
基本结构:ArrayList的底层是一个动态的数组,我们都知道数组是不可变的,所以一旦涉及到该集合的变化,其底层都是去创建一个新的数组,然后在进行数据的转移。
-
扩容机制:当我们实例化ArrayList集合时,会实例出一个空的集合。当我们第一次使用add方法时,在没有指定集合大小的情况下,集合会使用默认的数字10作为集合的容量。当数据长度已经达到集合的容量阈值时,如果我们继续使用add方法,则集合的容量大小会变为之前的1.5倍
-
该集合如何完成复制操作:直接使用其对应的clone方法、构造方法中传入结合对象、使用其addAll方法完成复制
-
集合特性:由于其底层的数据结构时数组的原因,所以增删数据时会改变数组的结构,特别是在扩容阈值附近,这会是十分耗费资源的。所以如果我们的操作中增删比较多,不建议使用该集合,如果非要是使用那么尽量设置好合适的集合初始大小。当然也由于其是数组的特点,其查找的时间复杂度为O(1),效果非常好。
-
安全性:该集合是不安全的,多线程情况下不能使用。
3、LinkList
-
基本结构:其底层是一个双向链表结构。每一个节点的头部用来存放上一个节点的地址值,尾部用来存放下一个节点的地址值,首节点的头部和末尾节点的尾部都为null,并且还需要维护一个链头节点,其中维护着链表的长度,首节点的地址值和末尾节点的地址值。
-
相关特点:该集合由于是数组结构,所以需要花费空间去维护一个节点的的头部和尾部,比较耗费资源,但是带来的好处是,当我们插入或者删除数据是直接在对应的位置上进行操作即可,效率上比ArrayList高很多。当然由于其结构是一个双向链表结构,所以在数据的查找效率上也没有那么慢。
-
安全性:该集合也是不安全的,多线程下不适合使用。
4、ConcurrentHashMap
- 基本结构:它和HashTable一样,都是线程安全的Map集合,但是我们在并发情况下,一般都是使用ConcurrentHashMap,因为它的并发性更高。在JDK1.7及以前ConcurrentHashMap是由Segment数组和HashEntry数组构成。JDK1.8时使用CAS+synchronized的结构来处理并发(锁的粒度又变细了)。**Segment锁又名分段锁,由于Segment在实现上继承了ReentrantLock,所以它也具有锁的功能。它与传统锁的区别可以理解为,曾经我们是一个集合一把锁,操作只能串行化操作。现在我们多拿几把锁,并且将我们的集合分为一段一段的,每一段使用一个锁。这个段的大小,由对应的并发度决定。**CAS和synchronized我们在多线程部分详解。当然除了锁机制外,其余部分可以参照HashMap的结构
- JDK1.7的初始化:ConcurrentHashMap初始化时,计算出Segment数组的大小和每个Segment中HashEntry数组的大小(即计算一共使用几把锁、集合分段后每一段中数组的大小),并初始化Segment数组的第一个元素;其中Segment集合大小为2的幂次方(锁的数量为2的幂次方把),默认为16,cap大小也是2的幂次方,最小值为2(集合分段后每一段的技术数量),最终结果根据初始化容量initialCapacity进行计算。
- JDK1.8的相关特点:只有在执行第一次put方法时才会调用initTable()方法初始化我们的Node数组,其实这与部分集合的特点类似,在添加数据时才初始化容量。
- 只简单学习了,没有深入源码,后面自行学习
5、Vector
- 可以简单理解为一个加锁的ArrayList
- 默认初始大小为10,每次扩容为前一次大小的两倍
- 数据结构不复杂,与ArrayList有多处类似
5、数据结构
这是谈及的只有我知道的几种数据结构,这一块的知识点,更多的需要与算法结合,面试的时候编程题很多都是这一块的,平时得注重积累,并且多刷题。
1、红黑树(用于理解HashMap、ConcurrentHashMap、TreeMap、TreeSet底层)
- 每个节点都只能是红色或者黑色
- 根节点是黑色
- 每个叶节点(NIL节点,空节点)是黑色的。
- 如果一个结点是红的,则它两个子节点都是黑的。也就是说在一条路径上不能出现相邻的两个红色结点。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
2、平衡二叉树
- 非叶子节点最多拥有两个子节点
- 非叶子节值大于左边子节点、小于右边子节点
- 树的左右两边的层级数相差不会大于1
- 没有值相等重复的节点
3、B树(平衡多路查找树)
相比较于平衡二叉树,该树拥有了多路查找的特点,即能够拥有很多的分支,使得我们的树的高度能够降低,在MySQL的角度来讲,高度越底,磁盘IO的次数就越少,那么性能就越高
4、B+树
B+树的结构可以理解为key(值)-value(地址)。其大部分的特点都与B树相同,主要的区别为B+树某一层节点的数据,一定包含了其父节点所有的数据信息,即该树的叶子节点包含了所有的数据信息,并且使用双向链表将数据进行了连接,这一特点也帮助B+树拥有了可以更加方便地使用区间查找数据,他不再需要向B树一样回溯节点,性能低下。
B树与B+树的区别:
- B+树节点不存储数据,所有data存储在叶子节点,导致查询的时间复杂度固定为log(n)。B树查找时间复杂度不固定,与key在树中的位置关系有关,最好的情况是1
- B+树叶子节点两两相连可大达增加区间访问性,可使用在范围查询等,而B树每个节点key和data在一起,则无法区间查找
- B+树更适合外部存储,由于内节点无data域,每个节点的索引范围更大更精确。
- 在数据结构上,B树为有序数组+平衡多叉树;B+树为有序数组链表+平衡多叉树
该部分的知识点,在考察数据库知识的时候极易被考到,比如:为什么MySQL要使用B+树作为其索引
6、数据库
除了前面说到的底层的数据结构,还有一些基本的点
1、MySQL的执行引擎MYISAM和INNODB的区别
数据表的类型比较上
MYISAM | INNODB | |
---|---|---|
事务支持 | 不支持 | 支持 |
数据行锁定 | 不支持 | 支持 |
外键约束 | 不支持 | 支持 |
全文索引 | 支持 | 不支持 |
表空间的大小 | 较小 | 较大,约为前者的2倍 |
常规使用操作:
- MYISAM:节约成本,速度较快
- INNODB:安全性高,事务的处理,多表多用户操作
所有的数据库文件都存在我们的data目录下,一个文件对应一个数据库,所以说,数据库存储的本质还是文件存储。
MySQL的两个不同的引擎在物理文件上的区别
- InnoDB:在数据库表中只有一个*.frm文件,以及一个上级目录下的ibadata1文件(较大)
- MYISAM:对应的文件
- *.frm:表结构的定义文件
- *.MYD:数据文件(data)
- *.MYI:索引文件(index)
2、MySQL语句的执行流程(执行的底层层面)
简单来说 MySQL 主要分为 Server 层和存储引擎层:
- Server 层:主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binglog 。
- 存储引擎: 主要负责数据的存储和读取,采用可以替换的插件式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎,其中 InnoDB 引擎有自带的日志模块 redolog 模块。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 开始就被当做了默认存储引擎。
我们通过客户端连接到我们的数据库,在连接器处进行一个身份和权限相关的验证,当我们执行一个SQL的时候,他会先去查询缓存中的内容(该功能在MySQL8.0中已经移除),如果有就返回给我们的客户端,没有的话就会进入到我们的分析器(如果移除了缓存这一块的话,我们查询的时候是直接进入分析器的)。分析器就是分析我们该条SQL要干嘛,并且检查我们的SQL是否存在语法错误。分析完成后进入优化器,在优化器中,我们的SQL语句会按照MySQL认为的最优的执行方案去执行。优化器执行完毕之后,会将我们的SQL语句扔给执行器去执行,最终从存储引擎返回我们想要的数据。
3、MySQL语句的执行流程(SQL层面)
select
...
from
...
where
...
group by
...
having
...
order by
...
limit
...
我们可以这样理解:数据肯定是来自表里面,所以①from绝对是第一个,考虑到我们并不是所有的数据都需要,所以我们可以先进行②where条件的判断。条件判断完毕我们可以对数据进行分组,并且完成分组的条件判断,所以③group by一定是在④having前面,并且group by在where后面,接下来就是展示我们的数据了,所以就执行⑤select后面的字段信息。⑥order by是用来排序的,那么肯定就会在字段数据出现以后才能操作,即select执行后执行的是order by,最终我们再对数据进行一个⑦limit的操作
即顺序为:from --> where --> group by --> having --> select --> order by --> limit
再往上走就是EXPLAIN的运用、索引的建立、索引的优化、索引失效的解决办法、锁机制、主从复制读写分离等等
7、JVM
1、JMM内存结构
Java虚拟机主要分为几个部分:运行时数据区、执行引擎、本地库接口、本地方法库、类加载器子系统。而我们常说的JMM,指的是运行时数据区。我们可以将JMM分为如下几个部分:
- 线程私有:程序计数器(指向虚拟机字节码指令的位置,唯一一个不会出现OOM的区域)、虚拟机栈(栈的结构,即先进后出。内部有包含一个一个栈帧,栈帧由局部变量表、操作数栈、动态链接、方法出口等组成)、本地方法栈(用来连接native方法)
- 线程共享:方法区(包含我们的运行时常量池等结构)、堆(类的实例区,我们new对象以后的空间,包含我们新生区、幸存者0区、幸存者1区、老年区,我们的GC也是在这区域)
- 直接内存:不受JVM的GC管理
2、对象创建的步骤(JVM层面)
0、首先明白初始化和实例化的关系
new的全过程 = 初始化 + 实例化(这也是理解懒加载的关键,这个只是点可以和单例模式串起来)
类的初始化过程:
- 一个类要创建实例需要先加载并初始化该类
- main方法所在的类需要先加载和初始化
- 一个子类要初始化需要先初始化父类
- 一个类初始化就是执行<clint>() 方法
- () )方法由①静态类变量显示赋值代码和②静态代码块组成
- 类变量显示赋值代码和静态代码块代码从上到下顺序执行
- () 方法只执行一次
类的实例化过程:
- 实例初始化就是执行 <init>() 方法
- <init>() 方法可能重载有多个,有几个构造器就有几个 <init>() 方法
- () 方法由①非静态实例变量显示赋值代码和②非静态代码块、③对应构造器代码组成
- 非静态实例变量显示赋值代码和非静态代码块代码从上到下顺序执行,而对应构造器的代码最后执行
- 每次创建实例对象,调用对应构造器,执行的就是对应的 () 方法
- <init>() 方法的首行是super() 或 super(实参列表) ,即对应父类的 <init>() 方法
1、判断对象对应的类是否加载、链接、初始化:
虚拟机遇到一条new指令的时候,首先去检查这个指令的参数能否在元空间的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派机制的作用下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象
2、为对象分配内存:
首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
- 如果内存规整:如果内存是规整的,那么虚拟机将采用的时指针碰撞法来为对象分配内存。即所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式,一般使用带有整理过程的收集器时,使用指针碰撞
- 如果内存不规整:如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。即是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表种找到一块足够大的空间划分给对象实例,并更新列表上的内容,这种分配方式称为“空闲列表 ”
说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是规整又所有采用的垃圾收集器是否带有压缩整理功能决定
3、处理并发安全问题:
- 采用CAS失败重试,区域加锁保证更新的原子性
- 每个线程预先分配一块TLAB,可以通过参数来进行设定
4、初始化分配到空间:
所有属性设置默认值,保证对象实例字段在不赋值时可以直接使用
5、设置对象的对象头
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头重,这个过程的具体设置方式取决于JVM的具体实现
6、执行init方法进行初始化
在Java程序的视角来看,初始化算告一段落,接下来开始实例化代码。实例化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由于字节码中是否跟随着invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行实例化,这样一个真正可用的对象才算完全创建出来
2、执行引擎部分
核心部分为JIT(Just In Time Compiler)
一般翻译为即时编译器,这是是针对解释型语言而言的,而且并非虚拟机必须,是一种优化手段,Java的商用虚拟机HotSpot就有这种技术手段,Java虚拟机标准对JIT的存在没有作出任何规范,所以这是虚拟机实现的自定义优化技术。
当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”。JIT编译器会在运行时针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java的执行性能,
热点代码探测技术
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT百年一起编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或者简称为0SR(On Stack Replacement)编译。
一个方法究竟要被调用多少次,或者循环体究竟需要执行多少次循环才可以达到这个标准呢?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行,这里主要依靠的是热点探测技术。
目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。
采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)
- 方法调用计数器用于统计方法的调用次数
- 回边计数器用于统计循环体执行的循环次数
这个计数器就用于统计方法被调用的次数,它默认的阈值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。当然这个阈值也可以通过虚拟机参数来进行设置。
当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值+1,然后判断方法调用方法调用计数器与回边计数器值之后是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求
热度衰减
如果不做任何的设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器,那么这个方法的调用计数器就会被减少一半,这个过程就叫做方法调用计数器热度的衰减,而这段时间就称之为方法统计的半衰周期.
当然我们也可以使用参数对虚拟机进行设置,用来关闭热度衰减,即让方法计数器统计方法调用时的绝对次数。这样的话,只要系统运行的时间足够长,绝大部分的方法都会被编译为本地代码。
JIT分类
- C1:即Client模式
- C2:即Server模式(64位系统默认的方式)
- Graal:自JDK10起,HotSpot又加入了一个全新的即时编译器,Graal编译器。编译效果短短几年时间就追平了C2编译器。目前带有“实验状态”的标签,需要使用参数进行开启
AOT
在JDK9中引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)
在Java 9中引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。
所谓AOT编译,是与即时编译相对立的一个概念。即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的是,在程序运行之前,便将字节码转换为机器码的过程。
X.class --> X.class --> X.so
优点:
- Java虚拟机加载已经编译成二进制的库文件,可以直接执行。不需要等到即时编译器的预热,减少Java应用带给人们“第一次运行慢”的不良体验
缺点:
- 破坏了Java“一次编译,到处运行”的特点,必须为每个不同硬件、OS编译对应的发行包
- 降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知
- 还需要继续优化中,最初只支持Linux X64 Java base
3、String相关
基本概念
String实现了Serializable接口:表示字符串是支持序列化的
String实现了Comparable接口:表示String是可以比较大小的
String在JDK8及以前内部定义了final char[] value用于存储字符串诗句。在JDk9时改为了byte[]
字符串的底层是一个Map结构,具体来说就是一个固定大小的HashTable(简单理解为加锁的HashMap),其默认大小长度是1009(我们可以使用传入参数进行设置)。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了以后直接会造成的影响就是当调用String.intern() (该方法的作用为,主动将常量池中还没有的字符串对象放入池中,并返回此对象地址)时性能大幅度下降(类比HashMap理解)
位置变化
- Java 6及以前,字符串常量池存放在永久代(现在永久代已经被移除)
- Java 7中Oracle的工程师对字符串池的逻辑做了很大的修改,即将字符串常量池的位置调整到了Java堆内。
- 多有的字符串都保存在堆中,和其他普通对象一样,这样可以让你在进行调优应用时仅需调整堆大小就可以了
- Java 8元空间,字符串常量在堆
字符串的拼接
在JDK5.0之前使用的是StringBuilder(线程不安全的),在JDK5.0之后使用的是StringBuffer,后者是线程安全的,但是执行的效率更低
如果拼接符号的前后出现了变量,则等同于在堆空间中new String(),具体的内容为实现对像的拼接。此时的结果(字符串的比较结果)不等同于拼接符号前后的变量替换为内容相同的字符串。但是如果使用intern()方法后,又可以变为相等的了。
在我们的日常使用中,我们对于字符串的拼接更多的是运用在变量上。所以如果出现很多次的字符串拼接,建议先在外部new StringBuffer(),然后使用对应的append()方法,进行拼接。如果我们直接使用+进行拼接的话,其底层依然是与上面的执行逻辑一致,但是会new出更多的StringBuffer()对象,浪费资源。
创建对象数量:
//对象1: new String()在堆空间中创建
//对象2: 在常量池中创建的字符串a
String str1 = new String("a");
//对象1: 由于使用到了相加的操作,所有首先会创建一个StringBuilder对象
//对象2: 第一个new String()
//对象3: 在常量池中创建对应的常量a
//对象4: 第二个new String()
//对象5: 在常量池中创建对应的常量b
//对象6: 调用对应的toString方法,用于返回对应的结果字符串,多以还需要创建一个new String()
//注意:最后在我们的常量池中是不会在创建对应的常量ab
String str2 = new String("a") + new String("b"); //最终结果等价于new String("ab")
intern()
JDK1.6中,将这个字符串对象尝试放入串池
- 如果串池中有,则并不会放入。返回已有的串池中的对象的地址
- 如果没有,会把对象复制一份,放入串池,并返回串池中的对象地址(复制的是对象,会有新地址)
JDK1.7起,将这个字符串对象尝试放入串池
- 如果串池中有,则并不会放入。返回以后的串池中的对象的地址
- 如果没有,则会把对象的引用地址复制一份(复制的是地址,不会出现新地址),放入串池,并返回串池中的引用地址
4、垃圾回收算法
我们的垃圾回收区域主要是在堆和方法区
第一阶段的垃圾收集
1、引用计数法:
对每一个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况。对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就+1;当引用失效时,引用计数器就-1.只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,即可回收。
缺点:
- 需要单独存储一个计数器,增加存储空间的开销
- 每次赋值都需要更新计数器,伴随着运算操作会增加时间开销
- 无法处理循环引用的情况,这是十分致命的(A引用了B,B也引用了A,两者都不会变为0。这也是Java没有采用该方法的重要原因)
2、可达性分析:
GC Roots包括一下几类元素:
- 虚拟机栈中引用的对象(各个线程被调用的方法中使用到的参数、局部变量)
- 本地方法栈内JNI(通常说的本地方法)引用的对象
- 方法区中常量引用的对象
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用
- 反应Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
可达性分析的基本思路:
- 可达性分析算法是以根对象集合(GC Roots)为起点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
- 使用可达性分析算法后,内存中的存活对象就会被根对象集合直接或者间接连接着,搜索所走过的路径称为引用链(Reference Chain)
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象
第二阶段的垃圾收集
1、标记-清除算法
当堆中的有效空间被耗尽时,就会停止整个程序(也被称为stop the world,这个概念跟重要),然后进行两项工作,第一项是标记,第二项是清除
- 标记:Collector从根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象
- 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
2、复制算法
将或者的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
优点:
- 没有标记清除和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现内存碎片的问题
缺点:
- 此算法需要两倍的内存空间
- 对于G1这种拆分为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小
注意:如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量就会比较少
3、标记-压缩或者标记整理算法
标记-压缩算法的最终效果等同于标记-清除算法执行完毕后,再进行一次内存碎片整理,因此,也可以把它称之为标记-清除-压缩算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的,是否移动回收后的存活对象是一项优缺点并存的操作。
可以看出,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此依赖,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
优点:
- 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
- 消除了复制算法当中,内存减半的高昂代价
缺点:
- 效率上来说,该算法的要低于复制算法
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
- 移动过程中,需要全程暂停用户应用程序。即STW
垃圾回收算法的核心思路算法
1、分代收集算法
几乎所有的额GC都采用了分代收及算法,这也是面试的高频考点。在HotSpot虚拟机中,基于分代的概念,我们将堆分为年轻代和老年代
- 年轻代:区域相对老年代较小,对象生命周期短、存活率低,回收频率高
- 老年代:区域大,对象生命周期长,存活率高,回收频率比较低
分代收集算法可以结合堆的物理结构进行理解,继而反推期算法的
2、增量收集算法
如果一次性要将所有的垃圾进行处理,需要造成系统很长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集器完成。
总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。
缺点:在垃圾回收过程种,会间断的执行应用程序代码,虽然减少了系统的停顿时间,但是由于线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统的吞吐量下降。
3、分区算法
区别于分代,将对象的生命周期进行分类。分区算法将整个堆空间按照空间分为连续不同的小区间,每一个小区间都独立使用,独立回收。这种算法的好处就是可以控制一次回收多少个区间。换句话说就是可以控制每次GC的空间大小,继而达到控制时延的效果。
STW
Stop-The-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点类似于卡死的感觉,这个停顿称之为STW
-
我们的程序之所以感觉到卡,就是这个GC的频繁出现,导致用户的体验极差
-
STW与采用的GC回收器无关,各个回收器都会出现,只是大家会利用不同的算法优化的不同
5、经典的垃圾回收过程
这里说的经典指的是HotSpot虚拟机中垃圾回收器回收对象的执行流程。
我们新创建一个对象后,正常情况下是会出现在伊甸园区,也叫新生区(新生代对象),明白意思即可。(特殊情况为对象太大,大过整个伊甸园区或者是幸存者区,又或者是不那么大,但GC之后还是没有多余的空间存放对象)然后对象再转移到Survivor 区(对应的to区和from区是在动态变化的,记住一句谁空谁是to即可),对象在to区和from区中使用复制算法不断的循环反复15次之后,依然能够存活的对象便进入老年区(老年代对象),老年区的算法为标记整理算法和标记清除算法结合使用。
这里有几个概念需要梳理一下:
- 新生代中,伊甸园区和from区和to区的空间比值为,8:1:1。新生代和老年代的空间比值为,1:2。即三个空间的比值为,8(伊甸园区):1(from):1(to):20(老年区)
- 常听的GC调优有一部分就是调节对应区域的大小以及比值
- from区和to区的循环15次的次数也是可以使用参数进行修改的
- MinorGC:针对伊甸园区进行的垃圾回收
- Major GC:针对老年代进行的垃圾回收
- Full GC:针对全局进行的垃圾回收
G1的垃圾回收过程
它依然将堆空间进行了分代操作,但是没有分区。区别于经典的垃圾回收方式,它不再将空间进行固定的划分空间,而是维护一个一个的Region块(主要为这四类Eden、Survivor、Humongous、Old)。垃圾回收时根据具体的情况,对不同区域内的对象进行回收。总结出来就是逻辑上分代,物理上不分代。
当然过程远不止这么简单,这里提供几个学习的G1方向:
- G1的数据结构
- 对应GC的触发条件,以及GC时的变化
- 回收器的优点与不足
- 该回收器会牵扯出哪些并发问题
- 读写屏障在G1中是如何体现
- G1收集器和CMS收集器的有什么关系和区别
- G1垃圾回收器的发展方向及竞争对手
随着垃圾回收器技术的发展G1垃圾回收器,已经越来越受到重视了。当然也出现了越来越多的垃圾回收器。如:Epsilon、Shenandoah、ZGC、AliGC
6、垃圾回收器
7款经典的垃圾回收器
按照性能分:
- 串行回收器:Serial、Serial Old
- 并行回收器:ParNew、Parallel Scavenge、Parallel Old
- 并发回收器:CMS、G1
根据作用位置分:
-
新生代的收集器:Serial、ParNew、Parallel Scavenge
-
老年代的收集器:Serial Old、Parallel Old、CMS
-
整堆收集器:G1
常规的组合方式:
- Serial和Serial Old
- Serial和CMS(JDK8中已弃用,但是还可以使用。JDK9中完全移除)
- ParNew和CMS
- ParNew和Serial Old(JDK8中已弃用,但是还可以使用。JDK9中完全移除)
- Parallel Scavenge和Serial Old
- Parallel Scavenge和Parallel Old(JDK14中弃用了,移除之日指日可待)
- CMS和Serial Old(属于垃圾回收方案的后备组合方案)
补充:CMS GC在JDK14中被删除
本想一张图片都不放,结果还是忍不住放了一张。能用文字把一个概念或一个东西描述清楚是一件多么伟大的事情呀。
8、JUC及部分多线程底层
1、synchronized底层
基本概念
Java自带关键字,用来防止多线程下资源获取冲突的问题,可以用来锁普通方法(锁住的是对象实例),还可以锁静态方法(锁住的是模板Class类)
对应的锁对象monitor:每个对象都有个monitor对象,加锁就是在竞争monitor对象,代码块加锁是在前后分别加 上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的
核心组件(6个)
- Wait Set:哪些调用 wait 方法被阻塞的线程被放置在这里
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
- Owner:当前已经获取到所资源的线程被称为 Owner
- !Owner:当前释放锁的线程。
对象头
可以简单理解为我们的每个对象的头部使用一定的空间包含了一部分信息。我们的对象 = 对象头 + 对象实例数据(instance data) + 对齐填充(padding),对象头 = 对象运行时数据(mark word 8个字节) + 对象类型指针(class pointer )+ (如果是数组就还需要4个字节)。我们的锁升级就发生在对象头的对象运行时数据中,即我们说的mark work中。对象头中包含GC信息、锁状态…
锁升级过程(4个)
随着技术的发展,传统的排队等待的锁的方式,已经无法满足我们的需求,所以在JDK1.6的时候,synchronized关键字进行了优化,引入了一个锁升级的过程。在理解了对象头的基础上我们可以更好的理解这个过程
锁升级过程:(修改对应的标志位)
- 无锁:我们刚实例化一个对象时是无锁的状态
- 偏向锁:单个线程的时候,会开启偏向锁。我们也可以使用相关的设置来禁用偏向锁。
- 轻量级锁:当多个线程来竞争的时候,偏向锁会进行一个升级,升级为轻量级锁(内部是自旋锁),因为轻量级锁认为,我马上就会拿到锁,所以以自旋的方式,等待线程释放锁
- 重量级锁:由于轻量级锁过于乐观,结果迟迟拿不到锁,所以就不断地自旋,自旋到一定的次数,为了避免资源的浪费,就升级为我们最终的自旋锁。
2、ReentrantLock
ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完 成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等 避免多线程死锁的方法。
ReentrantLock 与 synchronized对比
- 都是可重入锁
- ReentrantLock是JDK的API,synchronized是JDK的关键字
- ReentrantLock 通过方法 lock()与 unlock()来进行加锁与解锁操作,与 synchronized 会 被 JVM 自动解锁机制不同,ReentrantLock 加锁后需要手动进行解锁。为了避免程序出 现异常而无法正常解锁的情况,使用 ReentrantLock 必须在 finally 控制块中进行解锁操 作。
- ReentrantLock 相比 synchronized 的优势是可中断、公平锁、多个锁。这种情况下需要 使用 ReentrantLock。
- ReentrantLock拥有精准唤醒,而synchronized只能随机唤醒,或者全部唤醒
3、线程池的理解
4大方法
- 创建一个只有一个线程的线程池
newSingleThreadExecutor()
- 创建一个可伸缩的线程池
newCachedThreadPool()
- 创建一个指定最大数量的线程池
newFixedThreadPool(3)
- 创建一个定长线程池,支持定时及周期性任务执行
newScheduledThreadPool()
7大参数
- 核心线程数量
- 最大线程数量
- 等待多久时间关闭最大线程数
- 等待多久时间关闭最大线程数的时间单位
- 使用消息队列,用来缓存线程
- 对应的创建线程的线程工程
- 使用何种拒绝策略
4种拒绝策略
- 多出来的线程,直接抛出异常
ThreadPoolExecutor.AbortPolicy()
- 谁开启的这个线程,就让这个线程返回给谁执行。比如main线程开启的,那就返回给main线程执
ThreadPoolExecutor.CallerRunsPolicy()
- 如果队列线程数量满了以后,直接丢弃,不抛出异常
ThreadPoolExecutor.DiscardPolicy()
- 队列满了以后,尝试去和最早的线程竞争,也不会抛出异常
ThreadPoolExecutor.DiscardOldestPolicy
过程:当我们想创建线程池时,我们可以使用Java自带的方式去创建我们的线程(4个方法)。也可以使用我们自定义的参数(更建议使用该方式,此方式能让我们更能够理解线程池的原理),我们线程池会使用对应的线程工厂去创建最大线程数量个线程(可以使用CPU密集型和IO密集型两种方式),但是只会开启核心线程数量个线程,当加载的线程越来越多,我们线程池中的线程数量会变为最大线程数,如果线程还在不断地增加,我们地线程则会进入到对应地队列中,如果还在增加,我们会出现对应的拒绝策略进行拒绝线程的添加。当我们线程执行完毕之后,线程池中就会空出来位置,我们会在参数指定的等待时间到了以后又变为核心线程数量个线程。
4、ThreadLocal
内部结构
ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证 各个线程的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。如果将多个线程比喻为一个类,单个线程比喻为一个方法,那么ThreadLocal变量就可以理解为局部变量。
设计变更
之前,在ThreadLocal内部维护一个map,然后将我们的线程设置为key,然后对应的参数设置为value,每一个线程去获取对应的value时,就去比照对应的key
之后,每一个Thread线程,单独维护一个ThreadLocalMap,这个对应Map的key为ThreadLocal实例本身,value为我们需要存储的值,是一个Object类型
ThreadLocalMap
顾名思义它是一个Map结构,类比HashMap我们能够想到其内部维护着Entry节点。其key为当前线程,value为对应的变量。既然是Map那么我们就很容易的联想到哈希冲突,在HashMap中采用拉链法(同一个哈希桶下连接一个链表或者红黑树),而在ThrealLocalMap中,则是采用线性探测法,即遇到冲突跳到下一个位置,如果还是有冲突,再下一个,遇到末尾了就跳到头节点继续上面的步骤。
对应的引用关系为,我们每次启动线程后,在我们的Thread内部就会维护一个ThreadLocalMap,内部含有很多的Entry节点;当我们创建ThredLocal变量时,就会在ThreadLocalMap对应的Entry节点的key上添加ThreadLocal的实例(这里使用的是弱引用),value上添加对应的变量。
ThrealLocal和弱引用与内存泄漏的关系
我们启动一个线程,就会维护一个对应的ThreadLocalMap,Map的key就是ThreadLocal,value是我们设置的值,当我们的值使用完毕以后,我们要做的应该是将该数据进行清除。但是由于我们的Map是由当前的线程维护,只要线程在对应的Map也会在,所以Map上的数据就会多出来很多无用的。我们再来看看对应节点的数据关系,value已经不需要了,key为一个ThreadLocal对象(但是数据已经使用完毕,所以对应的ThreadLocal引用也已经断开),此刻存在的引用为ThreadLocal与Entry节点的引用。如果他们的引用关系能去除,那么key节点就可以为null,继而清除无用的数据。那么怎么清除这个引用关系呢?ThreadLocalMap使用的是一个弱引用,只要看到ThreadLocal和它对应的引用关系消失,就直接将它进行垃圾回收,那么对应的key的引用关系也就不存在了。ThreadLocal清除无用的数据就是在每次执行操作前先检查一遍有没有key为null,有就先清除,继而避免造成内存泄漏。
强软弱虚引用
- 强引用:最普通的引用 Object o = new Object()
- 软引用:垃圾回收器, 内存不够的时候回收 (缓存)
- 弱引用:垃圾回收器看见就会回收 (防止内存泄漏)
- 虚引用:垃圾回收器看见二话不说就回收,跟没有一样 (管理堆外内存) DirectByteBuffer --> 应用到NIO Netty
5、队列
分类:
队列可以分为BlockingQueue(阻塞队列)、Deque(双端队列)、AbstractQueue(非阻塞队列)。在阻塞队列下面又可以分为LinkedBlockingQueue、ArrayBlockingQueue
队列操作相关的4组API
方式 | 抛出异常 | 有返回值,不抛出异常 | 阻塞等待 | 超时等待 |
---|---|---|---|---|
添加 | add() | offer() | put() | offer(,) |
移除 | remove() | poll() | take() | poll(,) |
获取队首元素 | element() | peek() | - | - |
SynchronousQueue(同步队列)与BlockingQueue(阻塞队列)不一样,同步队列不存储元素。每当同步队列put一个值以后,必须先使用take取出来,否则不能再put进去值。
6、Java8新特性
我新特性的理解还仅仅停留在概念层面,所以只能概念走一走
lambda表达式:在我们学习多线程处,已经学习过它。
链式编程:可以简单理解为被调用方法的返回类型就是这个调用对象的类,书写的形式像一根链子一样用 . 串起来
函数式接口:四大函数式接口,函数型接口(传入一个输入参数,返回一个输出参数。并且只要是函数式接口,就可以使用lambda表达式进行简化书写)、断定型接口(传入一个参数,根据对应的逻辑返回相应的boolean值)、消费型接口(只有输入没有返回值)、供给型接口(不需要传入参数,直接)
Stream流式计算:Stream流计算的底层就使用了大量的函数式接口和链式编程。可以简单的将Stream理解为一种计算方式,并且其计算起来特别快,当Stream结合链式编程,我们就可以连续不断的使用指定条件来筛选目标了。
7、volatile
如果问你对volatile的理解,直接就可以说下面的四点,然后再详细的阐述
- volatile是Java虚拟机提供轻量级的同步机制
- volatile保证可见性:线程与线程之间可以借助带有volatile关键字的变量进行标志通信。在单例模式的DCL懒汉式的情况下,我们就需要使用volatile关键字,来保证多线程下的该类实例是否已经创建的可见性。
- volatile不保证原子性:由于我们的变量完成相应的增加或者减少操作时不是一个原子操作(哪怕是num++,我们可以反编译我们的代码进行具体的查看),即我们的操作不是一步就能完成的,需要好几步。那么如果我们希望我们的变量保证原子性,那么我们就可以使用Java为我们提供的原子类,该原子类对于数据的添加操作就不再与之前的增加方式相同了,此时它与OS直接相关,在内存上修改值。这里就会有一个问题,我们学习Java的时候都知道,Java的底层是C++,怎么我们这里就可以和OS碰上了?其实在虽然我们是那么说Java的特点,但是我们的设计者还是为我们留了一个“后门”,这里的Unsafe类就是这个后门。如果你了解Unsafe类,那么你就又可以和面试官吹一会了
- 禁止指令重排:我们创建一个对象的过程十分复杂,但是大体的三个步骤为,1.分配内存,2.执行构造方法,3.指向地址。如果我们不使用volatile关键字,那么这三个步骤在多线程情况下就会出现顺序颠倒,继而影响我们的对象的创建(具体细节问题可以参考DCL单例模式的问题)。我们使用了volatile关键字就可以禁止指令重排,继而对象的创建只能一步一步的顺序执行。
8、CAS
CompareAndSwap(V,E,N),一个典型的
- V:要更新的值的内存地址值
- E:期望的旧值
- N:要更新新值
比较并交换锁。该锁主要有三个参数,其中V是一个共享变量,我们首先会拿着我们准备的E,去跟V进行比较,如果E == V,说明目前没有其它线程操作该变量,此时我们既可以把N值写入对象的V变量中。如果 E != V ,说明我们准备的这个E,已经被修改了,那么我们就要重新准备一个最新的E ,去跟V比较,直到比较成功后才能更新V的值为N,此处使用的方式为一个do{…} while循环不断地往返
ABA问题
一个比较经典的问题就是ABA问题,即在我们拿到值E去进行比较时,我们的值V被修改了两次,这一切显得是那么的自然且不被察觉,但是我们应该意识到的就是我们的数据已经不是我们最开始的那一个数字了。
我们解决的方式一般为,添加一个带有时间戳的原子操作类AtomicStampedReference对应的数值,我们的数据被修改时,除了更新数据本身外,对应的时间戳也会发生变化,这样我们就可以避免ABA问题了。
悲观锁和乐观锁
CAS是一种典型的乐观锁,而我们常说的synchronized(但升级后的synchronized锁在轻量级锁那一个阶段也是CAS锁,不过最终的升级版本还是一个悲观锁)是一个典型的悲观锁。区别我们是悲观还是乐观最主要的方式就是看我们是先加锁还是先操作,先加锁就是悲观,先操作就是乐观。在判断其他的锁时,同样使用该方式即可。
类比生活就是,别人靠近我们的时候我们先想到堤防别人,那么我们对他最开始就是一个悲观态度,认为他不简单;如果别人靠近我们的时候我们第一反应是和他无话不谈,那么我们就是一个乐观的心态,如果他未来欺骗了我们,我们再堤防他。
9、AQS
AbstractQueueSynchronizer
抽象队列同步器,并发包下的一个核心组件,里面有state变量、加锁线程变量等核心的东西,用来维护了加锁的状态
说到AQS顺便提一下ReentrantLock,ReentrantLock的底层就与AQS有关(我们可以理解为AQS是父类)。我们在使用ReentrantLock的时候都知道它是用来进行加锁和解锁的,但是它是如何进行加锁和解锁的呢?(synchronized底层是操作monitor对象)
答:ReentrantLock底层就是使用的AQS,AQS对象内部有一个核心变量state,为int型用来表示加锁的状态,初始化时为null。还有一个关键变量用来记录当前加锁的线程是哪一个线程,初始化时为null。当我们使用XX.lock() 对一个线程进行加锁操作时,线程会使用CAS(没错就是上面提到的乐观锁)来进行加锁操作,加锁成功以后(state = 0 时才能成功),再对加锁线程变量完成赋值。加锁失败时,对应的线程就会把自己放到AQS内部维护的等待队列中。如果再CAS操作的过程中,发现state恢复为了null,那么说明我们的线程释放了锁,队首的线程就会出队,然后重复之前加锁会有的动作。
可重入锁
当我们完成加锁以后,我们再次再次使用XX.lock() 会怎么样呢?
由于ReentrantLock是可重入锁,所以我们会再锁一层。体现到AQS上就是state变量值+1。同理,释放锁时值state值-1,最终state值又会变为null。
9、项目
项目阶段不知道如何总结,我也没有做过几个项目,但一般问题都是分为这几类:
- 请你简单说一下你的项目。
- 你的项目解决了什么问题?
- 你在做项目的过程中遇到了哪些问题?
- 你是如何解决这些问题?
- 通过这个项目你学到了什么?
10、面试题
1、Spring支持的事务传播属性和隔离界别
事务的传播行为可以由传播属性指定,Spring定义了7种类传播行为
传播属性 | 描述 |
---|---|
REQUIRED(required) | 如果有事务在运行,当前的方法就在这个事务内运行,否则,就启动一个新的事务,并在自己的事务内运行 |
REQUIRES_NEW(required_new) | 当前的方法必须启动新事务,并在它自己的事务内运行,如果有事务正在运行,应该将它挂起 |
SUPPORTS(supports) | 如果有事务在运行,当前的方法就在这个事务内运行,否则它可以不运行在事务中 |
NOT_SUPPORTED(not_supported) | 当前的方法不应该在事务中,如果有运行的事务,将它挂起 |
MANDATORY(mandatory) | 当前的方法必须运行在事务内部,如果没有正在运行的事务,就抛出异常 |
NEVER(never) | 当前的方法不应该运行在事务中,如果有运行的事务,就抛出异常 |
NESTED(nested ) | 如果有事务在运行,当前的方法就应该在这个事务的嵌套事务内运行,否则,就启动一个新的事务,并在它自己的事务内运行 |
并发问题和隔离级别
-
数据库事务并发问题
- 脏读:数据修改后又进行了回滚操作,我们读到的数据是修改后,回滚前的数据。即读到的数据是一个无效的数据
- 不可重复读:第一个事务读取到的数据后,当第二次再去读时,发现其他事务修改了数据。即两次数据读取的不一样(侧重字段数据)
- 幻读:第一个事务读取了表的数据,第二个事务对表中的数据行进行了增删改操作,第一个事务再来读时,表中的数据发生了变化。即两次读取数据的数据行不一样(侧重数据行)
-
隔离级别越高,数据一致性就越好,但并发性越弱。
- 读未提交READ_UNCOMMITTED(read_uncommitted):可以读取到其他事务还没有提交的修改
- 读已提交READ_COMMITTED(read_committed):只能读取到其它事务已提交的修改
- 可重复读REPEATABLE_READ(repeatable_read):事务在读数据对应字段的过程中,其他的事务不能对它进行相关的操作(侧重字段数据)
- 串行化SERIALIZABLE(serializable):对数数据行进行串行化,在此期间严禁其他事务的增删改操作(侧重数据行)
MySQL默认的隔离级别为:solation.REPEATABLE_READ(可重复度)
Oracle默认的隔离级别为:Isolation.READ_COMMITTED(读已提交)
隔离级别就是为了解决并发问题
2、类的初始化和实例化之间的关系
-
初始化
- 一个类要创建实例需要先加载并初始化该类(main方法所在的类最先开始)
- 一个子类要初始化需要先初始化父类
- 一个类初始化就是执行 <clint>() 方法(<clint>() )方法由①静态类变量显示赋值代码和②静态代码块组成)
-
实例化(执行 <init>() 方法)
- <init>() 方法可能重载有多个,有几个构造器就有几个 <init>() 方法
- <init>() 方法由①非静态实例变量显示赋值代码和②非静态代码块、③对应构造器代码组成
- 非静态实例变量显示赋值代码和非静态代码块代码从上到下顺序执行,而对应构造器的代码最后执行
- 每次创建实例对象,调用对应构造器,执行的就是对应的<init> () 方法
- <init>() 方法的首行是super() 或 super(实参列表) ,即对应父类的\ () 方法
我们的项目在运行的时候,会先初始化加载到内存中,遇到我们需要使用的时候,再new一下,即实例化,最终才变成了我们能够使用的类。这一点可以结合单例模式的饿汉式和懒汉式的区别一起看。
3、方法参数的传递机制
实参给形参赋值本质:
- 基本数据类型:数据值
- 引用数据类型:地址值
其中有几个点需要强调一下:
- 我们需要明白不同数据类型的数据在我们JVM中存储的位置
- Integer类型的数据,数字的大小不同则存储的位置也不一样(默认-128~127存储在方法区,超过范围的数据存储在堆中),这就导致了它在不同情况下数据是否相等
- String类型:该数据类型是一个不可变的,当我们对该数据类型进行了拼接操作时,其原来的字符串依然存在。我们的创建过程,可以理解位间接的完成了一次数据引用地址的转换。
4、成员变量和局部变量的区别
-
成员变量:
- 分类:成员变量又可以分为:实例变量(没有static修饰)+ 类变量(有static修饰)
- 声明位置:类中,方法外
- 修饰符:public、protect、private、final、static、volatile、transient(序列化)
- 存储位置:实例变量在堆中,类变量在方法区中
- 作用域:实例变量,在当前类中 “this.” (有时this.可以省略),在其他类中 “对象名.” 访问;类变量,在当前类中 “类名.” (有时类名. 可以省略),在其他类中“类名.” 或 “对象名.” 访问
- 生命周期:实例变量,随着对象的创建而初始化,随着对象的被回收而消亡,每一个对象的实例化变量是独立的;类变量,随着类的初始化而初始化,随着类的卸载而消亡,该类的所有对象的类变量是共享的
-
局部变量:
- 声明位置:方法体{}中、形参、代码块{}中
- 修饰符:final
- 存储位置:栈中
- 作用域:从声明处开始,到所属的 } 结束
- 生命周期:每一个线程,每一次调用执行行都是新的生命周期
5、SpringMVC解决乱码问题
-
POST乱码:编写一个对应的字符过滤器,并且在web.xml中完成配置
-
GET乱码:在tomcat服务器中添加对应的UTF-8编码
-
同时解决:编写一个较复杂的字符过滤器类,可以同时解决POST和GET问题