0
点赞
收藏
分享

微信扫一扫

java知识-整合

路西法阁下 2022-04-14 阅读 45
java

目录

JAVA基础

Object的常用方法

wait(),notify(),notifyAll(),toString(),getClass()

★常见的运行时异常

(1)java.util.ConcurrentModificationException:并发修改异常,对线程不安全的对象进行并发写操作引起
(2)java.lang.StackOverflowError:栈溢出,可能是因为方法无限递归导致的
(3)OutOfMemoryError:Java.heap.space:堆溢出
①java虚拟机的堆内存设置不够,可以通过设置-Xms和-Xmx来调整
②代码中创建了大量大对象,且长时间存在引用

数据结构

红黑树
五个基本性质:不红红,黑路同,左根右,根叶黑
(1)红黑树的每个节点,要么红色,要么黑色
(2)根节点root一定是黑节点
(3)所有的红色节点都不能直接相连,即不能有两个连续的红节点
(4)所有的空结点(叶子节点)都是黑色
(5)从任意节点出发到达某个叶子节点,所经过的黑节点数量相同,即黑高相同
时间复杂度:
红黑树的搜索插入和删除的时间复杂度都是O(logn)的
java中的HashMap,TreeMap和TreeSet中都使用到了红黑树,红黑树也属于二叉平衡树的一种,不过我们一般的二叉平衡树要求的是更严厉的平衡,而红黑树只要求黑高相同即可,两者相比,二叉平衡树的查找效率更高(因为最多只有一层高度差),而红黑树的插入删除效率更快

集合框架图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x3sBuUR3-1649743615554)(C:\Users\朱峰\AppData\Roaming\Typora\typora-user-images\image-20220411211322616.png)]

1.Vector(动态数组)

2.ArrayList(动态数组)

(1)new Vector():创建一个初始化长度为10的数组,默认增量为0
(2)new ArrayList():
JDK1.6版本:底层初始化创建的是一个长度为10的空数组
JDK1.7版本:底层初始化创建的是一个长度为0的空数组
JDK1.8版本:底层初始化的也是一个长度为0的空数组
(3)为什么JDK1.7以后创建的都是长度为0的空数组?
因为在开发中很多情况下创建了ArrayList后并不会往里放值,所以创建长度为0的数组节省空间
(4)add(Object e):
JDK1.8:如果是第一次添加元素,会先将底层的数组扩容成长度为10的数组,如果之后添加数组长度不够则会扩容成1.5倍

3.Stack(栈)

4.List实现类们的区别

ArrayList、Vector、LinkedList、Stack
(1)ArrayList、Vector:都是动态数组
Vector是最早版本的动态数组,线程安全的,默认扩容机制是2倍,支持旧版的迭代器Enumeration
ArrayList是后增的动态数组,线程不安全的,默认扩容机制是1.5倍
(2)动态数组(Vector,ArrayList)与LinkedList的区别
动态数组:底层物理结构是数组
优点:根据[下标]访问的速度很快
缺点:需要开辟连续的存储空间,而且需要扩容,移动元素等操作
LinkedList:底层物理结构是双向链表
优点:在增加、删除元素时,不需要移动元素,只需要修改前后元素的引用关系
缺点:我们查找元素时,只能从first或last开始查找
(3)Stack:栈
是Vector的子类。比Vector多了几个方法,能够表现出“先进后出或后进先出”的特点。
①Object peek():访问栈顶元素
②Object pop():弹出栈顶元素
③push():把元素压入栈顶
(4)LinkedList可以作为很多种数据结构使用
单链表:只关注next就可以
队列:先进先出,找对应的方法
双端队列(JDK1.6加入):两头都可以进出,找对应的方法
栈:先进后出,找对应的方法
建议:虽然LinkedList是支持对索引进行操作,因为它实现List接口的所有方法,但是我们不太建议调用类似这样的方法,因为效率比较低。

5.Set

Set是如何判断两个元素是否相同的?
HashSet和LinkedHashSet:会先比较hash值,如果hash值不一样,说明这两个元素一定不同,如果hash值相同再调用equals方法比较
TreeSet:是使用Compareable或Comparetor中的compareTo方法进行比较
List和Set的底层是用什么实现的?
List的底层是Object类型的数组
HashSet的底层是HashMap,TreeSet的底层是TreeMap,LinkedHashSet的底层是LinkedHashMap
Set底层的Map是将插入的元素作为key,将一个Object常量对象作为值,这样就可以保证元素的唯一性
TreeSet和TreeMap的key的大小依赖于,java.lang.Comparable或java.util.Comparator。

6.Map

(1)关于HashMap的面试问题
HashMap在JDK1.8的时候为什么从头插法变成了尾插法

​ 答:因为头插法在高并发情况下,有两个线程同时进行扩容操作的话会有可能出现环链问题,这个下次进行get操作的时候会导致程序卡住,使用尾插法改变了数据的插入顺序,避免了这个风险
同时HashMap在JDK1.8的时候也对hash函数函数进行了优化,利用了高16位,降低了hash冲突发生的概率
//如果key是null,hash是0
//如果key非null,用key的hashCode值 与 key的hashCode值高16进行异或/
// 即就是用key的hashCode值高16位与低16位进行了异或的干扰运算
/*
index = hash & table.length-1
如果用key的原始的hashCode值 与 table.length-1 进行按位与,那么基本上高16没机会用上。
这样就会增加冲突的概率,为了降低冲突的概率,把高16位加入到hash信息中。
*/
​ return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

HashMap的底层实现

​ 答:JDK1.7是数组+链表,JDK1.8是数组+链表/红黑树

HashMap的数组的元素类型

​ 答:java.util.Map$Entry接口类型。
​ JDK1.7的HashMap中有内部类Entry实现Entry接口
​ JDK1.8的HashMap中有内部类Node和TreeNode类型实现Entry接口

为什么要使用数组?

​ 答:因为数组的访问的效率高

为什么数组还需要链表?或问如何解决hash或[index]冲突问题?

​ 答:为了解决hash和[index]冲突问题
​ Ⅰ.两个不相同的key的hashCode值本身可能相同
​ Ⅱ.两个hashCode不相同的key,通过hash(key)以及 hash & table.length-1运算得到的[index]可能相同
那么意味着table[index]下可能需要存储多个Entry的映射关系对象,所以需要链表

HashMap的数组的初始化长度

​ 答:默认的初始容量值是16

HashMap的映射关系的存储索引index如何计算

​ 答:hash & table.length-1

为什么要使用hashCode()? 空间换时间

​ 答:因为hashCode()是一个整数值,可以用来直接计算index,效率比较高,用数组这种结构虽然会浪费一些空间,但是可以提高查询效率。

hash()函数的作用是什么

​ 答:在计算index之前,会对key的hashCode()值,做一个hash(key)再次哈希的运算,这样可以使得Entry对象更加散列的存储到table中
​ JDK1.8关于hash(key)方法的实现比JDK1.7要简洁。

​ key.hashCode() ^ key.Code()>>>16; 因为这样可以使得hashCode的高16位信息也能参与到运算中来

HashMap的数组长度为什么一定要是2的幂次方

​ 答:因为2的n次方-1的二进制值是后面都0,前面都是1,这样的话,与hash进行&运算的结果就能保证在[0,table.length-1]范围内,而且是均匀的。

HashMap为什么使用&按位与运算代替%模运算?

答:因为&效率高

HashMap的数组什么时候扩容?

​ 答:JDK1.7版:当要添加新Entry对象时发现(1)size达到threshold(2)table[index]!=null时,两个条件同时满足会扩容
​ JDK1.8版:当要添加新Entry对象时发现(1)size达到threshold(2)当table[index]下的结点个数达到8个但是table.length又没有达到64。两种情况满足其一都会导致数组扩容
​ 而且数组一旦扩容,不管哪个版本,都会导致所有映射关系重新调整存储位置。

如何计算扩容阈值(临界值)?

​ 答:threshold = capacity * loadfactor

loadFactor为什么是0.75,如果是1或者0.1呢有什么不同?

​ 答:1的话,会导致某个table[index]下面的结点个数可能很长
​ 0.1的话,会导致数组扩容的频率太高

JDK1.8的HashMap什么时候树化?

​ 答:当table[index]下的结点个数达到8个但是table.length已经达到64

JDK1.8的HashMap什么时候反树化?

​ 答:当table[index]下的树结点个数少于6个

JDK1.8的HashMap为什么要树化?

​ 答:因为当table[index]下的结点个数超过8个后,查询效率就低下了,修改为红黑树的话,可以提高查询效率

JDK1.8的HashMap为什么要反树化?

​ 答:因为因为当table[index]下树的结点个数少于6个后,使用红黑树反而过于复杂了,此时使用链表既简洁又效率也不错

作为HashMap的key类型重写equals和hashCode方法有什么要求

①equals与hashCode一起重写
②重写equals()方法,但是有一些注意事项;
• 自反性:x.equals(x)必须返回true。 对称性:x.equals(y)与y.equals(x)的返回值必须相等。 传递性:x.equals(y)为true,y.equals(z)也为true,那么x.equals(z)必须为true。 一致性:如果对象x和y在equals()中使用的信息都没有改变,那么x.equals(y)值始终不变。 非null:x不是null,y为null,则x.equals(y)必须为false。
③重写hashCode()的注意事项
• 如果equals返回true的两个对象,那么hashCode值一定相同,并且只要参与equals判断属性没有修改,hashCode值也不能修改; 如果equals返回false的两个对象,那么hashCode值可以相同也可以不同; 如果hashCode值不同的,equals一定要返回false; hashCode不宜过简单,太简单会导致冲突严重,hashCode也不宜过于复杂,会导致性能低下;

为什么大部分 hashcode 方法使用 31?

​ 答:因为31是一个不大不小的素数

请问已经存储到HashMap中的key的对象属性是否可以修改?为什么?

​ 答:如果该属性参与hashCode的计算,那么不要修改。因为一旦修改hashCode()已经不是原来的值。 而存储到HashMap中时,key的hashCode()–>hash()–>hash已经确定了,不会重新计算。用新的hashCode值再查询get(key)/删除remove(key)时,算的hash值与原来不一样就不找不到原来的映射关系了。

所以为什么,我们实际开发中,key的类型一般用String和Integer

​ 答:因为他们不可变。

为什么HashMap中的Node或Entry类型的hash变量与key变量加final声明?

​ 答:因为不希望你修改hash和key值

为什么HashMap中的Node或Entry类型要单独存储hash?

​ 答:为了在添加、删除、查找过程中,比较hash效率更高,不用每次重新计算key的hash值

请问已经存储到HashMap中的value的对象属性是否可以修改?为什么?

​ 答:可以。因为我们存储、删除等都是根据key,和value无关。

如果key是null是如何存储的?

​ 答:会存在table[0]中

(2)StringBuffer和StringBuilder,Vector和ArrayList,HashTable和HashMap之间的差别
StringBuffer和StringBuilder:

都是字符串变量,是可改变的,每当我们使用它们对字符串做操作时,实际上是在一个对象上操作的,不像String一样是重新创建对象进行操作,速度快
StringBuilder:线程非安全的,StringBuffer:线程安全的
但是StringBuilder快过StringBuffer

Vector和ArrayList

ArrayList是在Vector后面出的,Vector是线程安全的,但效率较低,而且ArrayList使用的半倍增,而Vector使用的是倍增的思想

HashTable和HashMap

HashMap是在HashTable后面出的,HashTable是线程安全的,但效率较低,而且HashMap允许null值,而HashTable不允许null值,HashTable有一个很常用的子类Properites可以用来编写配置文件

JAVA8特性

1.lambad表达式

(拷贝小括号,写死右箭头,落地大括号,同时注意一下方法和构造的简写)
由小括号(),->,和大括号{}组成,小括号中写方法的参数,大括号中写具体的方法实现
适用场景:函数式接口,@FunctionalInterface

2.接口中允许有default(默认)和static(静态)方法

​ 默认方法可以不强制重写,也不会影响到已有的实现类

3.函数式接口

在这里插入图片描述

4.Stream流

Stream是一种工具,Stream自己不会存储数据,它会返回一个新的Stream对象,而且Stream的filter等方法都是延迟执行的,只有执行到结果方法了,才一起过滤
通过filter进行过滤,然后通过forEach等结果方法进行结果的返回
Stream的使用:
(1)创建一个Stream对象(数据源):list.stream()
(2)中间操作,处理数据源数据
(3)一个终止操作,执行中间操作链,产生结果
分为串行流和并行流,并行流处理虽然比串行流快,但是会有数据安全问题,所以我们一般都用串行流。

JVM(java虚拟机规范)

在这里插入图片描述

Java栈,本地方法栈和程序计数器这些灰色区域线程私有,是没有垃圾回收机制的,因为他们的生命周期太短了,方法区和堆,线程共享,生命周期较长有垃圾回收

1.类加载器

用于加载java的class文件,有四种(三种虚拟机自带的,一种用户自定义)
(1)启动类加载器:用c++编写,用于加载JVM启动所需的基础类,如Object等
(2)扩展类加载器,用于加载第三方jar包
(3)应用程序类加载器,用于加载程序员自定义的类
Java的类加载器有沙箱安全机制和双亲委派机制
沙箱安全机制是上一级加载器加载的类能覆盖子加载器加载的类
双亲委派机制是当某个类加载器需要加载某个.class文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
这两个机制保证了java的核心类安全
加载类的过程:加载->链接->初始化
先将class类文件加载,然后将二进制代码链接到运行状态的jvm中,最后电泳cinit()方法初始化类。

2.执行引擎

功能是将本地方法请求翻译成操作系统能理解的命令

3.本地方法栈

用于处理native方法,这些方法在出栈的时候需要调用本地方法接口(操作系统,c/c++的接口)进行执行,可能还需要本地方法库的第三方支持

4.程序计数器(线程私有)

就是一个指针,用来存储指向下一条指令的地址,即将要执行的代码
在线程结束的时候一起销毁,生命周期短不需要垃圾回收

5.java栈(线程私有,先进后出)

用于存放:8种基本数据类型的变量+对象的引用+实例方法
Java栈由一个一个栈帧组成,栈帧用于存放:
本地变量:输入参数和输出参数以及方法内的变量
栈操作:记录出栈,入栈操作
栈帧数据:包括类文件、方法等
逻辑上是在一块的,但是物理上是两块区域

6.方法区(线程共享)

用来存放:静态变量+常量+类信息(构造方法/接口定义),运行时开辟的常量池也在方法区中
方法区是程序运行的环境,在JVM停下来的时候才销毁,因此有垃圾回收

7.堆(线程共享)

在java7及以前,堆在逻辑上分为三个区:新生区,养老区和永久区
在java8及以后,堆在逻辑上还是分为三个区:新生区,养老区和元空间
但是在物理上都是两个区,永久区或者元空间在物理上都在(方法区中)
元空间是开辟在本地内存中的和JVM关系不大,将方法区中的类信息+常量+静态变量+常量池移动到元空间中
新生区(存在普通GC,伊甸区快满的时候发生):
分为三个部分:伊甸区(刚出生的对象),幸存0区,幸存1区(8:1:1)
复制(算法)必交换,谁空谁是to
在伊甸区的对象经历的GC后能幸存下来的对象进入幸存区,然后再0区1区反复横跳,经历了15次(默认,因为分代年龄只有4bit,最大就是15)GC后还幸存的进入养老区(基本上都是池对象才能活这么久,看出身的散了吧),但是如果伊甸区快满了,就直接进入养老区(空间较大)
养老区(存在full GC,全局GC,养老区快满的时候发生)
养老区如果满了就会报异常:OutOfMemoryError:Java.heap.space

栈+堆+方法区之间的关系?

方法区中存放的是class模板,堆中存放new出来的对象,栈中存放对象的引用
字符串常量池存放在哪个区?
在JDK1.6的时候存放在方法区,在JDK1.7的时候存放在堆中,在JDK1.8的时候存放在元空间

如何判断一个对象是否可以被回收

引用计数法

Java中,引用和对象是有关联的。如果要操作对象则必须用引用进行。
因此,很显然一个简单的办法就是通过引用计数来判断一个对象是否可以回收。简单说,给对象中添加一个引用计数器
每当有一个地方引用它,计数器值加1
每当有一个引用失效,计数器值减1
任何时刻计数器值为零的对象就是不可能再被使用的,那么这个对象就是可回收对象。
那么为什么主流的Java虚拟机里面都没有选用这个方法呢?其中最主要的原因是它很难解决对象之间相互循环引用的问题。
该算法存在但目前无人用了,解决不了循环引用的问题,了解即可。

枚举根节点做可达性分析

根搜索路径算法
为了解决引用计数法的循环引用问题,Java使用了可达性分析的方法:
所谓 GC Roots 或者说 Tracing Roots的“根集合” 就是一组活跃的引用
基本思路就是通过一系列名为 GC Roots的对象作为起始点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活,没有被遍历到的对象就被判定为死亡
在这里插入图片描述

**哪些对象可以当做GC Roots
• 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中的引用对象
• 方法区中的类静态属性引用的对象
• 方法区中常量引用的对象
• 本地方法栈中的JNI(Native方法)的引用对象

堆参数调优

JVM参数类型

标配参数(从JDK1.0 - Java12都在,很稳定)

  • -version
  • -help
  • java -showversion

X参数(了解)

  • -Xint:解释执行
  • -Xcomp:第一次使用就编译成本地代码
  • -Xmixed:混合模式

XX参数(重点)

  • Boolean类型
    • 公式:-XX:+或者-某个属性 +表示开启,-表示关闭
    • Case:-XX:-PrintGCDetails:表示关闭了GC详情输出
  • key-value类型
    • 公式:-XX:属性key=属性value
    • 不满意初始值,可以通过下列命令调整

查看参数

​ jps:查看java的后台进程
​ jinfo:查看正在运行的java程序
​ jstack:查看指定Java进程的详细信息
​ 具体使用:
​ jps -l得到进程号
​ jinfo -flag PrintGCDetails 12608
​ 就可以得到:-XX:-PrintGCDetails
​ -号表示关闭,即没有开启PrintGCDetails这个参数
在这里插入图片描述

题外话(坑题)

两个经典参数:-Xms 和 -Xmx,这两个参数 如何解释

这两个参数,还是属于XX参数,因为取了别名

​ ①-Xms 等价于 -XX:InitialHeapSize :初始化堆内存(默认只会用最大物理内存的64分1)

​ ②-Xmx 等价于 -XX:MaxHeapSize :最大堆内存(默认只会用最大物理内存的4分1)

如何查看JVM默认参数

(1)-XX:+PrintFlagsInitial
主要是查看初始默认值
(2)公式
java -XX:+PrintFlagsInitial -version
java -XX:+PrintFlagsInitial(重要参数)
-XX:+PrintFlagsFinal:表示修改以后,最终的值
会将JVM的各个结果都进行打印
如果有 := 表示修改过的, = 表示没有修改过的
使用 -XX:+PrintCommandLineFlags 打印出JVM的默认的简单初始化参数
(3)生活常用调优参数
-Xms:初始化堆内存,默认为物理内存的1/64,等价于 -XX:initialHeapSize
-Xmx:最大堆内存,默认为物理内存的1/4,等价于-XX:MaxHeapSize
-Xss:设计单个线程栈的大小,一般默认为512K~1024K,等价于 -XX:ThreadStackSize
-XX:MetaspaceSize:设置元空间大小,元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存,因此,默认情况下,元空间的大小仅受本地内存限制。但是默认的元空间大小:只有20多M
-XX:PrintGCDetails:输出详细GC收集日志信息

内存快照抓取和MAT分析DUMP文件知道吗?

​ MAT是Eclipse提供的一个工具,可以帮助我们解读DUMP文件,生成饼状图等帮助我们分析OOM。DUMP文件可以通过-XX:+HeapDumpOnOutOfMemoryError设置在抛出OOM异常后生成DUMP文件
如果没有MAT插件也可以使用jdk提供的jvisualvm.exe解读DUMP文件

Java中的引用

强引用

​ 当内存不足的时候,JVM开始垃圾回收,对于强引用的对象,就算是出现了OOM也不会对该对象进行回收,打死也不回收~!

软引用

(Java.lang.ref.SoftReference)
当系统内存充足时,它不会被回收
当系统内存不足时,它会被回收

弱引用

不管内存是否够,只要有GC操作就会进行回收(java.lang.ref.WeakReference)
1.软引用和弱引用的使用场景
假如有一个应用需要读取大量的本地图片
如果每次读取图片都从硬盘读取则会严重影响性能
如果一次性全部加载到内存中,又可能造成内存溢出
此时使用软引用可以解决这个问题
设计思路:使用HashMap来保存图片的路径和相应图片对象关联的软引用之间的映射关系

​ 在内存不足时,JVM会自动回收这些缓存图片对象所占的空间,从而有效地避免了OOM的问题
​ Map<String, SoftReference> imageCache = new HashMap<String, SoftReference>();
2.WeakHashMap是什么?
​ 比如一些常常和底层打交道的,mybatis等,底层都应用到了WeakHashMap
​ WeakHashMap和HashMap类似,只不过它的Key是使用了弱引用的,也就是说,当执行GC的时候,HashMap中的key会进行回收
​ 对于普通的HashMap来说,key置空并不会影HashMap的键值对,因为这个属于强引用,不会被垃圾回收。
但是WeakHashMap,在进行GC操作后,弱引用的就会被回收

虚引用java.lang.ref.PhantomReference

​ 如果一个对象持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列ReferenceQueue联合使用。
虚引用的主要作用和跟踪对象被垃圾回收的状态,仅仅是提供一种确保对象被finalize以后,做某些事情的机制
​ PhantomReference的get方法总是返回null,因此无法访问对象的引用对象。其意义在于说明一个对象已经进入finalization阶段,可以被gc回收,用来实现比finalization机制更灵活的回收操作
​ 换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候,收到一个系统通知或者后续添加进一步的处理,Java技术允许使用finalize()方法在垃圾收集器将对象从内存中清除出去之前,做必要的清理工作
这个就相当于Spring AOP里面的后置通知

引用队列 ReferenceQueue

软引用,弱引用,虚引用在回收之前,需要在引用队列保存一下
我们在初始化的弱引用或虚引用的时候,可以传入一个引用队列

GCRoots和四大引用小总结

• 红色部分在垃圾回收之外,也就是强引用的
• 蓝色部分:属于软引用,在内存不够的时候,才回收
• 虚引用和弱引用:每次垃圾回收的时候,都会被干掉,但是它在干掉之前还会存在引用队列中,我们可以通过引用队列进行一些通知机制

Java内存溢出OOM

经典错误

JVM中常见的两个错误
StackoverFlowError :栈溢出
OutofMemoryError: java heap space:堆溢出
除此之外,还有以下的错误
• java.lang.OutOfMemoryError:GC overhead limit exceeeded
• java.lang.OutOfMemoryError:Direct buffer memory
• java.lang.OutOfMemoryError:unable to create new native thread
• java.lang.OutOfMemoryError:Metaspace

架构[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zYg6VjoE-1649743615560)(C:\Users\朱峰\AppData\Roaming\Typora\typora-user-images\image-20220412100647305.png)]

OutOfMemoryError和StackOverflowError是属于Error,不是Exception

GC overhead limit exceeded

GC回收时间过长时会抛出OutOfMemoryError,过长的定义是,超过了98%的时间用来做GC,并且回收了不到2%的堆内存
连续多次GC都只回收了不到2%的极端情况下,才会抛出

unable to create new native thread

​ 不能够创建更多的新的线程了,也就是说创建线程的上限达到了
​ 在高并发场景的时候,会应用到
​ 高并发请求服务器时,经常会出现如下异常java.lang.OutOfMemoryError:unable to create new native thread,准确说该native thread异常与对应的平台有关
导致原因:
​ 应用创建了太多线程,一个应用进程创建多个线程,超过系统承载极限,服务器并不允许你的应用程序创建这么多线程,linux系统默认运行单个进程可以创建的线程为1024个,如果应用创建超过这个数量,就会报 java.lang.OutOfMemoryError:unable to create new native thread
解决方法:

1.想办法降低你应用程序创建线程的数量,分析应用是否真的需要创建这么多线程,如果不是,改代码将线程数降到最低

2.对于有的应用,确实需要创建很多线程,远超过linux系统默认1024个线程限制,可以通过修改linux服务器配置,扩大linux默认限制

Metaspace

元空间内存不足,Matespace元空间应用的是本地内存
-XX:MetaspaceSize 的初始化大小为20M
Metaspace是方法区HotSpot中的实现,Metaspace并不在虚拟内存中,而是使用本地内存,也即在java8中,class metadata(the virtual machines internal presentation of Java class),被存储在叫做Matespace的native memory中存放了以下信息:
• 虚拟机加载的类信息
• 常量池
• 静态变量
模拟Metaspace空间溢出,我们不断生成类 往元空间里灌输,类占据的空间总会超过Metaspace指定的空间大小

GC(分代收集算法),结合JVM来理解

GC是什么?

GC是分代收集算法:频繁收集Young区,较少收集Old区,基本不动Perm区

★GC四大算法的原理及各自的优缺点?

1.引用计数法(已被淘汰,不用了):
对每一个对象都维护一个计数器,用于表示这个对象的引用个数,一旦数字降到0就回收
每个对象都需要维持一个计数器太伤性能,而且没办法处理循环应用
2.复制算法(在新生区使用):
将对象从伊甸区或者幸存from区复制到幸存to区,然后把原本的删除
效率高,无内存碎片,但是复制过程需要两倍的内存空间,新生区存活率低,且对象小,适宜使用
3.标记清除(用在Old区)
对Old区的对象扫描两遍,第一遍进行标记,第二遍将标记的对象进行清除
不需要两倍的内存空间,但扫描两遍效率较低,而且在第二遍的清除过程需要将JVM的功能停一下,用户体验不太好
4.标记压缩(用在Old区)
也是扫描两遍,第一遍也是标记,第二遍是将未标记的对象滑动排在一起,标记的对象移动走清除
这个避免了内存碎片,但是效率还是低
5.标记清除压缩(用在Old区)
在先用标记清除算法进行清除,清除了几次后再用标记压缩算法消除内存碎片

四种主要的垃圾收集器类型

①Serial:串行回收 -XX:+UseSeriallGC
②Parallel:并行回收 -XX:+UseParallelGC
③CMS:并发标记清除
④G1
⑤ZGC:(java 11 出现的)

Serial

串行垃圾回收器,它为单线程环境设计且值使用一个线程进行垃圾收集,会暂停所有的用户线程,只有当垃圾回收完成时,才会重新唤醒主线程继续执行。所以不适合服务器环境

Parallel

并行垃圾收集器,多个垃圾收集线程并行工作,此时用户线程也是阻塞的,适用于科学计算 / 大数据处理等弱交互场景,也就是说Serial 和 Parallel其实是类似的,不过是多了几个线程进行垃圾收集,但是主线程都会被暂停,但是并行垃圾收集器处理时间,肯定比串行的垃圾收集器要更短

CMS

并发标记清除,用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行),不需要停顿用户线程,互联网公司都在使用,适用于响应时间有要求的场景。并发是可以有交互的,也就是说可以一边进行收集,一边执行应用程序。

G1

G1垃圾回收器将堆内存分割成不同区域,然后并发的进行垃圾回收

垃圾收集器总结[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zkpj20VA-1649743615562)(C:\Users\朱峰\AppData\Roaming\Typora\typora-user-images\image-20220412101141487.png)]

注意:并行垃圾回收在单核CPU下可能会更慢

默认垃圾收集器有哪些

Java中一共有7大垃圾收集器

​ ①UserSerialGC:串行垃圾收集器
​ ②UserParallelGC:并行垃圾收集器
​ ③UseConcMarkSweepGC:(CMS)并发标记清除
​ ④UseParNewGC:年轻代的并行垃圾回收器
​ ⑤UseParallelOldGC:老年代的并行垃圾回收器 ⑥UseG1GC:G1垃圾收集器
​ ⑦UserSerialOldGC:串行老年代垃圾收集器(已经被移除)
没有年轻代的串行垃圾收集器

各垃圾收集器的使用范围

新生代使用的:
①Serial Copying: UseSerialGC,串行垃圾回收器
②Parallel Scavenge:UseParallelGC,并行垃圾收集器
③ParNew:UseParNewGC,新生代并行垃圾收集器
老年区使用的:
①Serial Old:UseSerialOldGC,老年代串行垃圾收集器
②Parallel Compacting(Parallel Old):UseParallelOldGC,老年代并行垃圾收集器
③CMS:UseConcMarkSwepp,并行标记清除垃圾收集器
各区都能使用的:
G1:UseG1GC,G1垃圾收集器

新生代下的垃圾收集器(配置后会将老年代一起绑定配置了)

新生代都使用复制算法,老年代都采用标记-整理算法

串行GC(UseSerialGC)

配置后,新生代、老年代都会使用串行回收收集器

并行回收GC(UseParallelGC)

新生代和老年代都是使用并行,

并行GC(UseParNewGC)

新生代用并行垃圾回收器,老年代用串行垃圾回收器
但是会出现警告,即 ParNew 和 Serial Old 这样搭配,Java8已经不再被推荐

老年代下的垃圾收集器

串行GC(UseSerialOldlGC)

在JDK1.5之前版本中与新生代的并行垃圾收集器搭配使用(Parallel Scavenge + Serial Old)
现在已经不用了,但是这个收集器会作为,CMS收集器的后备垃圾收集方案

并行GC(UseParallelOldGC)

设置该参数后,新生代Parallel+老年代 Parallel Old,配置效果貌似和UseParallelGC一样嘛

并发标记清除GC(UseConcMarkSweepGC)

是一种以最短回收停顿时间为目标的收集器
开启该参数后,会自动使用ParNew(young 区用)+ CMS(Old 区用) + Serial Old 的收集器组合,Serial Old将作为CMS出错的后备收集器

四个步骤

①初始标记(CMS initial mark)
只是标记一个GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程
②并发标记(CMS concurrent mark)和用户线程一起
进行GC Roots跟踪过程,和用户线程一起工作,不需要暂停工作线程。主要标记过程,标记全部对象
③重新标记(CMS remark)
为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程,由于并发标记时,用户线程依然运行,因此在正式清理前,在做修正
④并发清除(CMS concurrent sweep)和用户线程一起
清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。基于标记结果,直接清理对象,由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS收集器的内存回收和用户线程是一起并发地执行。
优点:并发收集低停顿
缺点:并发执行,对CPU资源压力大,采用的标记清除算法会导致大量碎片

由于并发进行,CMS在收集与应用线程会同时增加对堆内存的占用,也就是说,CMS必须在老年代堆内存用尽之前完成垃圾回收,否则CMS回收失败时,将触发担保机制,串行老年代收集器将会以STW方式进行一次GC,从而造成较大的停顿时间
标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩,CMS也提供了参数 -XX:CMSFullGCSBeForeCompaction(默认0,即每次都进行内存整理)来指定多少次CMS收集之后,进行一次压缩的Full GC

为什么新生代采用复制算法,老年代采用标整算法

(1)新生代使用复制算法
因为新生代对象的生存时间比较短,80%的都要回收的对象,采用标记-清除算法则内存碎片化比较严重,采用复制算法可以灵活高效,且便于整理空间。
(2)老年代采用标记整理
标记整理算法主要是为了解决标记清除算法存在内存碎片的问题,又解决了复制算法两个Survivor区的问题,因为老年代的空间比较大,不可能采用复制算法,占用内存空间

G1垃圾收集器

开启G1垃圾收集器

-XX:+UseG1GC

G1是什么

(1)G1:Garbage-First 收集器,是一款面向服务端应用的收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能满足垃圾收集暂停时间的要求。
(2)G1收集器设计目标是取代CMS收集器,它同CMS相比,在以下方面表现的更出色
(3)G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
(4)G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。
核心思想是将整个堆内存区域分成大小相同的子区域(Region),在JVM启动时会自动设置子区域大小
启动时可以通过参数-XX:G1HeapRegionSize=n 可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
大小范围在1MB~32MB,最多能设置2048个区域,也即能够支持的最大内存为:32MB*2048 = 64G内存

回收步骤

Eden区的数据移动到Survivor区,假如出现Survivor区空间不够,Eden区数据会晋升到Old区
Survivor区的数据移动到新的Survivor区,部分数据晋升到Old区
最后Eden区收拾干净了,GC结束,用户的应用程序继续执行

四步过程

①初始标记:只标记GC Roots能直接关联到的对象
②并发标记:进行GC Roots Tracing(链路扫描)的过程
③最终标记:修正并发标记期间,因为程序运行导致标记发生变化的那一部分对象
④筛选回收:根据时间来进行价值最大化回收

参数配置

开发人员仅仅需要申明以下参数即可
三步归纳:

-XX:+UseG1GC -Xmx32G -XX:MaxGCPauseMillis=100
-XX:MaxGCPauseMillis=n:最大GC停顿时间单位毫秒,这是个软目标,JVM尽可能停顿小于这个时间

G1和CMS比较

G1不会产生内存碎片
可以精准控制停顿。该收集器是把整个堆(新生代、老年代)划分成多个固定大小的区域,每次根据允许停顿的时间去收集垃圾最多的区域。

SpringBoot结合JVMGC

启动微服务时候,就可以带上JVM和GC的参数
①IDEA开发完微服务工程
②maven进行clean package
③要求微服务启动的时候,同时配置我们的JVM/GC的调优参数
我们就可以根据具体的业务配置我们启动的JVM参数
例如:

java -Xms1024m -Xmx1024 -XX:UseG1GC -jar   xxx.jar

Java 并发编程

ThreadLocal的理解

ThreadLocal是怎么实现线程锁定的?在1.7和1.8版本中进行了什么改进?

JDK1.7版本:

由ThreadLocal这个类,维护一个大ThreadLocalMap,将Thread对象作为key,数据作为值,这样每个Thread都只会取到自己的数据,但是这样做的坏处是,当Thread一多就会不好管理。

JDK1.8版本:

​ 换成由每个Thread都单独维护一个小ThreadLocalMap,将当前Thread对象中的ThreadLocal对象作为key,数据作为值,好处:这样便于管理,且当线程结束了以后,ThreadLocalMap也会被回收,节省了内存

​ 如果ThreadLocalMap中key使用的强引用,那么我们把Thread中的ThreadLocal置为null了以后,堆中的ThreadLocal对象也不会被gc,这样就有可能造成内存泄漏

​ 所以我们把key的引用变成弱引用,这样当我们把Thread中的ThreadLocal置为null了以后,堆中的ThreadLocal对象也就会被gc,但是ThreadLocalMap中的key=null和value还是存在的,还是有可能造成内存泄漏,不过几率小一些(那这样为什么不用WeakHashMap来代替?不对它用的就是WeakHashMap)

​ 因为到了1.8之后每个Thread都自己维护了一个ThreadLocalMap,所以我们在设置值和获取值的时候都是先获取到Thread中的这个map再通过ThreadLocal对象去hash,找到数据所在位置的,和1.7不同,所以不用奇怪为什么放入map中的都是同一个ThreadLocal对象,因为他们是多个map,不像1.7用的是一个大map,所以用Thread对象作为key

请谈谈你对 volatile 的理解

volatile 是 Java 虚拟机提供的轻量级的同步机制
(1)保证可见性
volatile的内存语义:
①当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立刻刷新回主内存中
②当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
③所以volatile变量的读写都是相当于直接操作主内存
(2)保证有序性(禁止指令排序)
volatile 实现禁止指令重排序的优化,从而避免了多线程环境下程序出现乱序的现象
(3)不保证原子性
volatile变量的复合操作(如:i++)不具备原子性
读取到本地工作空间,和写回到主内存的操作时原子的,但是只有这两步时原子的。
可能你读到本地工作空间后,还在计算过程中会有其他线程将此时主内存的值读取,就没法保证原子性。

JMM(Java 内存模型) 你谈谈

基本概念

(1)JMM 本身是一种抽象的概念并不是真实存在,它描述的是一组规定或规范,通过这组规范定义了程序中的访问方式。

(2)每个线程创建时 JVM 都会为其创建一个工作内存,工作内存是每个线程的私有数据区域,而 Java 中所有变量储存在主内存,主内存是共享内存区域,所有的线程都可以访问,但线程对变量的操作(读取赋值等)必须都工作内存进行。

(3)线程对数据进行操作时,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

JMM的三大特性

①可见性
②原子性(volatile不能保证)
③有序性

单例模式的实现

(1)利用volatile实现

多线程环境下可能存在的安全问题,由于优化重排的原因,会发现构造器里的内容会多次输出

DCL(Dueue Checked Lock)双重锁单例也一样
如果没有加 volatile 就不一定是线程安全的,原因是指令重排序的存在,加入 volatile 可以禁止指令重排。

原因是在于某一个线程执行到第一次检测,读取到的 instance 不为 null 时,instance 的引用对象可能还没有完成初始化。

instance = new Singleton() 可以分为以下三步完成	memory = allocate();  // 1.分配对象空间
instance(memory);     // 2.初始化对象
instance = memory;    // 3.设置instance指向刚分配的内存地址
instance != null

步骤 2 和步骤 3 不存在依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种优化是允许的。
发生重排

memory = allocate();  // 1.分配对象空间
instance = memory;    // 3.设置instance指向刚分配的内存地址,此时instance != null,但对象还没有初始化完成
instance(memory);     // 2.初始化对象

所以不加 volatile 返回的实例不为空,但可能是未初始化的实例

(2)采用静态内部类的方式实现[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X4L2Bf01-1649743615563)(C:\Users\朱峰\AppData\Roaming\Typora\typora-user-images\image-20220412102703337.png)]

因为内部类为private所以其他类不能访问,而等内部类加载静态变量就已经初始化完成了。

JMM多线程先行发生原则之happens-before原则

如果JMM中,一个操作的执行结果要对另一个操作可见,或者需要指令重排,那么这两个操作应该遵循happens-before原则。JMM进行指令重排保证有序性的原则。

(1)两条简略内容:
①如果一个操作happens-before另一个操作,那么第一个操作的执行结果要对第二个操作可见,且第一个操作应该先执行
②但是如果两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么允许指令重排。

(2)happens-before的完整八条内容
①次序规则:一个线程内的代码顺序执行
②锁定规则:上一个线程unlock释放锁,下一个线程才能lock获得锁
③volatile变量规则:volatile变量的写操作要在读操作之前执行
④传递规则:A在B前,B在C前,则A在C前
⑤线程启动规则:线程中的代码要先start之后才能执行
⑥线程中断规则:中断方法interrupt,要在获取中断标志位的操作前执行
⑦线程终止规则:线程的所有操作都发生在线程终止检测操作前
⑧对象终结规则:一个对象的初始化完成,发生在它的finalize()方法之前
如果这八条都不满足,则表示这两个操作之间不存在happens-before关系,可能会出现乱序

volatile的内存屏障(面试重点)

内存屏障即内存屏障指令,指一类JVM指令,是底层原语级别的约束。
JMM的指令重排规则要求java编译器在生成JVM指令时插入指定的内存屏障指令,通过这些指令volatile就能实现可见性和有序性。
(1)作用:
①阻止两边的指令重排序
②写数据时加入屏障,强制将线程私有工作内存的数据刷回主内存
③读数据时加入屏障,线程私有工作内存的数据失效,重新到主内存中获取最新值
在这里插入图片描述
(2)四类内存屏障指令
Unfafe类的底层有本地方法,其C++源码中有loadload(),storestore(),loadstore(),storeload()四个方法
在这里插入图片描述

屏障类型 指令实例 说明
loadload() load1;
loadload();
load2;
load2的读操作在load1的读操作之后执行
(1读后2读)
storestore() store1;
storestore();
store2;
store2的写操作在store1的写操作之后执行
(1写后2写)
loadstore() load1;
loadstore();
store2;
store2的写操作在load1的读操作之后执行
(1读后2写)
storeload() store1;
storeload();
load2
load2的读操作在store1的写操作之后执行
(1写后2读)

(3)volatile的变量的指令排序规则
①当第一个操作为volatile读时,不论第二个操作是什么,都不能指令重排(保证了volatile读之后的操作不会排到读之前)
②当第二个操作为volatile写时,不论第一个操作是什么,都不能重排(保证了volatile写之前的操作不会排到写之后)
③当第一个操作为volatile写时,第二个操作为volatile读时,不能重排(volatile写的优先级比读高)
(4)禁止指令排序
volatile写:
①在每个volatile写操作的前面插入一个storestore屏障(写后写,你写完我再写)
②在每个volatile写操作的后面插入一个storeload屏障(写后读,我写完你再读)
在这里插入图片描述
volatile读:
①在每个volatile读操作的后面插入loadload屏障,保证load2读的时候之前读的缓存失效,去主内存读(读后读,我读完你再读)
②在每个volatile读操作的后面再插入一个loadstore屏障(读后写,我读完你再写)

CAS 你知道吗?(比较并交换,compareAndSwap)

CAS 是什么

CAS 的全称 Compare-And-Swap,它的功能是判断内存某一个位置的值是否为预期,如果相同则更改这个值,因为其底层是利用汇编语言进行编写的,所以这个过程是原子的。

分析一下 getAndAddInt 这个方法,就是一种CAS的思想

// unsafe.getAndAddInt
public final int getAndAddInt(Object obj, long valueOffset, long expected, int val) {
    int temp;
    do {
        temp = this.getIntVolatile(obj, valueOffset);  // 获取快照值
    } while (!this.compareAndSwap(obj, valueOffset, temp, temp + val));  // 如果此时 temp 没有被修改,就能退出循环,否则重新获取
    return temp;
}

CAS 底层原理?谈谈对 UnSafe 的理解?

Unsafe 是 CAS 的核心类,由于 Java 方法无法直接访问底层系统,而需要通过本地(native)方法来访问, Unsafe 类相当一个后门,基于该类可以直接操作特定内存的数据。

①AtomicInteger的原子性就是通过UnSafe类和CAS思想进行保证的
②AtomicInteger中的变量 vauleOffset,表示该变量值在内存中的偏移量,因为 Unsafe 就是根据内存偏移量来获取数据的。
③变量 value 用 volatile 修饰,保证了多线程之间的内存可见性。

CAS 的缺点?

①循环时间长开销很大
②如果 CAS 失败,会一直尝试,如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销(比如线程数很多,每次比较都是失败,就会一直循环),所以希望是线程数比较小的场景。
③只能保证一个共享变量的原子操作,对于多个共享变量操作时,循环 CAS 就无法保证操作的原子性。即可能会导致写丢失,如:volatile int i=0;i++执行1000次可能最后的值只有900多;
因为CAS会写失败,比较失败了就会重新获取重新加
o 引出 ABA 问题

原子类 AtomicInteger 的 ABA 问题谈一谈?原子更新引用知道吗?

①原子引用(atomicReference<>)
AtomicReference和AtomicInteger非常类似,不同之处就在于AtomicInteger是对整数的封装,底层采用的是compareAndSwapInt实现CAS,比较的是数值是否相等,而AtomicReference则对应普通的对象引用,底层使用的是compareAndSwapObject实现CAS,比较的是两个对象的地址是否相等。也就是它可以保证你在修改对象引用时的线程安全性。

②ABA 问题是怎么产生的
有两个线程,其中有一个线程将数据的值从 A 改为 B 又改为 A,而另一个线程通过CAS进行比较成功更改,这就是 ABA 问题。

​ 解决方法:时间戳原子引用(加上版本号,类似于乐观锁)stamp(atomicStampReference<>)

一次性原子引用AtomicMarkableReference?

这个其实比较像是AtomicStampReference的简化版,它比较的不是版本号,而是有一个标识,标识它是否有被修改过
在这里插入图片描述

JUC

java中Thread类的六种状态:

new(新建),Runnable(运行时),Blocked(堵塞),Waiting(等待),Timed_Waiting(有时限的等待,过时不候),Terminated(终结)

线程和进程的区别?

一个进程包含一个或多个线程,一个软件运行便是一个进程,而线程指的只是进程中一个任务的进行

wait和sleep的区别?

wait在等待时会释放手中的资源,而sleep不会,但二者都是在哪睡在哪醒,不会回退,这就导致了我们需要注意线程之间的虚假唤醒

什么是并发,什么是并行?

并发指的是同一个时刻有两个请求都需要进行操作,例如:秒杀案例中多个请求同时发过来就是并发,我们用ab进行高并发模拟,有两种情况,一开始是超卖,这个我们可以用Redis的事务+乐观锁进行解决,但是之后有引发了库存遗留的问题,这个我们是通过LUA脚本进行解决的(可以把并发变成串行),这种方式在Redis的集群环境中没办法使用,可以考虑用Redis中的List结构进行解决,选出前几个秒杀成功。

并行指的是同一时刻做多件事,如泡面

乐观锁和悲观锁,公平锁和非公平锁,可重入锁(递归锁)?

乐观锁和悲观锁

乐观锁认为世界上都是好人,因此它不加锁,但是每个请求在读数据时都会读到一个版本号,在进行写操作时会用自己手中的版本号和数据的版本号进行比较,如果相同则写操作可以成功,如果不同则说明期间有其他人动过数据,则写操作失败。最常用的是CAS算法,java原子类用的就是CAS+乐观锁

悲观锁认为世界上的都是坏人,因此在进行操作的时候都会加锁,sycnhronized和Lock的实现类都是悲观锁。

公平锁和非公平锁

非公平锁:抢,谁抢到就是谁的
公平锁:指根据等待锁的时间进行排队获取锁,就不会出现所有请求全部都是一个线程完成的情况,但是因为要排队,所以性能比非公平的慢

非公平锁的特点:不需要排队,所以刚释放锁的线程再次获取到锁的概率很大,减少了线程切换的开销。但是可能导致线程饥饿的问题,即所有请求都由一个线程执行。

可重入锁(递归锁)

指同一个线程在外层方法获取锁后,进入上了相同锁(锁对象是同一个)的内层方法会自动获取锁,不会因为之前自己获取锁还没有释放而阻塞。可以一定程度上避免死锁。

lock和synchronized的区别?

(1)Synchronized能实现的功能lock都能实现,而且lock更加灵活,因为加锁和释放锁的操作都是我们手动进行的,而synchronized是自动帮我们完成的。Lock是接口使用的是它的实现类ReenteLock(可重入锁),synchronized是关键字
(2)等待是否可中断
synchronized 不可中断,除非抛出异常或者正常运行完成。
ReentrantLock 可中断,设置超时方法 tryLock(long timeout, TimeUnit unit),lockInterruptibly() 放代码块中,调用 interrupt() 方法可中断。
(3)是否公平
synchronized 非公平锁
ReentrantLock 默认非公平锁,构造方法中可以传入 boolean 值,true 为公平锁,false 为非公平锁。
(4)锁可以绑定多个 Condition
synchronized 没有 Condition。
ReentrantLock 用来实现分组唤醒需要唤醒的线程们,可以精确唤醒,而不是像 synchronized 要么随机唤醒一个线程要么唤醒全部线程。

LockSupport和线程中断

(1)Interrupt方法和中断标志

①首先,我们觉得一个线程的中断应该由自己控制,而不是由其他线程强行停止,因此Thread.stop(),Thread.suspend(),Thread.resume()都被废弃了

②其次,线程的中断非常重要不能舍弃,因此我们设置了interrupt中断协商方法

③若要中断一个线程,我们需要调用interrupt方法,该方法不是强制停止线程,而是将线程的中断标志位设置成ture,接着我们需要自己写代码不断检查当前线程的中断标志位,如果为ture说明有其他线程想要我们停止,此时要怎么做你自己编写代码。

④interrupt方法既可以由其他线程调用,也可以由自己调用,反正又不是强制停

⑤如果要interrupt的线程正处于阻塞状态(wait,sleep等),则直接报错InterruptException,并清空中断状态(设回false),所以这个可能会导致我们程序停不下来,此时我们可以在异常处理的时候再调一次interrupt
在这里插入图片描述
中断的方法:
在这里插入图片描述

Thread中的API:
在这里插入图片描述

(2)LockSupport

LockSupport是JUC下的一个类,是用来创建锁和其他同步类的基本线程阻塞原语。有park()和unpark()两个静态方法,使用unsafe类实现,作用分别是阻塞线程和解除阻塞线程
LockSupport类使用了Permit(许可)的概念来做到线程的阻塞和唤醒。Permit只有0和1两个值(默认是0)
在这里插入图片描述

(3)线程等待唤醒机制(按次序输出)

在这里插入图片描述
①使用synchronized
Object的wait和notify方法必须放在同步代码块或者同步方法中,并且需要先wait后notify成对顺序执行,所以不好用
notify是随机唤醒一个

②使用lock
Condition的await和signal方法也必须放在lock和unlock中,且也需要先await后notify成对顺序执行
使用Condition可以精确指定唤醒哪个
在这里插入图片描述
在这里插入图片描述
有点类似我们操作系统的pv操作

③使用LockSupport
是使用静态方法park和unpark,来实现线程的阻塞和唤醒,而且因为是java8才出的技术,所以在park后调用interrupt方法不会像wait和sleep一样报错。
在这里插入图片描述
这个阻塞和唤醒甚至没有同步代码块,极大的加快了效率
但是Permit的最大就是1最小就是0,所以你多次unpark也只能把Permit加到1,解一次unpark

★ArrayList是否为线程安全?不是,那怎么解决线程不安全的问题

(HashSet有CopyOnWriteArraySet,HashMap有ConcurrentHashMap)
ArrayList不是线程安全的,因为它源码中add方法上没有加synchronizedList关键字,也没有使用lock
(1)可以使用vector,但是vector太老了,效率太低
(2)也可以使用Collections.synchronizedList(new ArrayList<>())把ArrayList编程线程安全的,但同样效率不高
(3)最好的办法是使用JUC的CopyOnWriteArrayList类(写时复制),这样效率会高

为什么CopyOnWriteArrayList效率高?

​ 因为CopyOnWriteArrayList采用了写时复制技术,即在线程进行写操作时,会复制一份旧版本的List给读线程进行读取,当写操作完成,再将旧版本更新成新版本,这样既保证了写操作线程安全,又保证了效率

★ConcurrentHashMap的JDK1.7和JDK1.8的源码分析,以及进行了什么优化?

ConcurrentHashMap在JDK1.7的时候使用的是分段锁的概念:ReentrantLock+Segment(继承了ReentrantLock,能加锁)+HashEntry

​ 因为像HashTable这种直接加synchronized加的锁太重,而且其实大部分情况两个元素的hash值是不同的,因此我们在进行写操作的时候只需要锁住自己当前元素所hash到的数组桶位即可,不需要锁整个数组,这也是分段锁的思想

​ JDK1.7版本中,ConcurrentHashMap的数据结构是由一个Segment数组和多个HashEntry组成,Segment数组的意义就是将一个大的table分割成多个小的HashEntry数组来进行加锁,也就是上面的提到的锁分离技术

​ Segment的大小ssize默认为16,这个Segment的长度都是2的n次方,需要注意的是每一个Segment里的HashEntry长度少是2,在初始化的时候会判断

put:加锁只会加在当前Segment对象上

​ Segment实现了ReentrantLock,带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒

get:

跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null

ConcurrentHashMap在JDK1.8:synchronized+CAS+HashEntry(或者叫Node/TreeNode)+红黑树

ConcurrentHashMap在JDK1.8的实现其实更像是一个升级版的HashMap,也是链表和树化,反树化,甚至什么阈值,负载因子都是一样的,在JDK1.8版本中Segment的概念被废除了,取而代之的是Node,TreeNode和synchronized

put:

​ 1.8用的不是Segment进行锁的控制了,而是使用sychronizeded关键字(锁的是一个HashEntry数组桶位,更细了),防止多个线程同时操作同一个bucket,插入完成后再判断是否进行树化
步骤:
​ 如果数组没有初始化就先调用initTable()方法来进行初始化过程(同样是懒加载,扩成16)
​ 如果没有hash冲突就直接CAS插入
​ 如果还在进行扩容操作就先进行扩容(其他线程可以通过helpTransfer()函数帮助transfer()扩容)
​ 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入

​ 最后,如果该链表的数量大于阈值8,就要先转换成黑红树的结构,break再一次进入循环
如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容

get:

操作类似

ConcurrentHashMap和SynchronizedHashMap的区别?

ConcurrentHashMap类似于行锁,在一个线程进行操作时,其他线程还是可以进行读操作和一些数据的写操作的。

SynchronizedHashMap类似于表锁,一个线程进行操作时其他线程都不能进行操作。

★synchronized实现线程同步的基础?

Java中的每一个对象都可以作为锁
对于普通同步方法,锁是当前实例对象
对于静态同步方法,锁是当前类的class对象
对于同步代码块,锁是synchronized括号里配置的对象,抢的是对象

Java中对象的组成

java中的对象分为三部分,对象头,实例数据,对其填充数据
对象头:用于存储synchronized锁状态,对象hashCode值等信息
实例数据:存储成员变量
对其填充数据:因为HotStop中要求java对象的大小必须是8字节的整数倍,这块数据就是帮我们对齐用的(方便对象的查找)

synchronized锁升级的过程(无锁->偏向锁->轻量级锁->重量级锁)

在这里插入图片描述

synchronized的原理

​ 在java中的对象在创建的时候其实都在JVM底层绑定了一个monitor对象(管程,Objectmonitor对象),这个对象才是保障线程安全的关键,线程抢占synchronized中的对象其实抢的是这个对象绑定的Objectmonitor对象(底层命令的字节码是monitorenter和montiorexit),Objectmonitor底层会维护一个计数器每lock一次就+1,每unlock一次-1,0表示没有线程占用。(因此lock和unlock要成对出现)

​ 如果线程发现此时计数器不为0且持有对象不是自己,会调用wait方法阻塞,这就是锁抢占的底层过程。

​ monitor对象可以调用一些内核方法,如让后来的进程阻塞,唤醒等操作,但是因为执行内核方法需要CPU从用户态转变为内核态,效率很低,所以导致synchronized效率低,monitor对象,也被称为重量级的锁
在这里插入图片描述

偏向锁

​ 因为大部分情况下,锁是不存在竞争的,而是一个线程频繁的进出同步代码块,这样如果每次进出都进行抢占锁和释放锁的操作效率是很低的,因此java引入了偏向锁的概念

​ 过程:当一个线程抢占到锁了以后会先判断,如果当前锁对象是无锁状态,那就将这个对象变成偏向锁状态,同时将自己的线程ID写入到这个锁对象的对象头中,下次再来访问同步代码块的时候只需要检查锁标记位和线程ID即可,不需要抢占锁和释放锁的操作了。

​ 只适用于单个线程的情况,一旦发现有多个线程进行抢占,就会在安全点升级成轻量级锁

轻量级锁

轻量级锁适用于线程按顺序交替执行,不存在线程竞争的情况,在这种情况下轻量级锁的消耗比重量级锁要高

自旋锁

因为重量级锁对性能的影响很大,所以在从轻量级锁转换成重量级锁之前会先经过自旋锁,挣扎一下

因为重量级锁一旦线程抢占锁资源失败就会直接堵塞,但是线程的阻塞和唤醒是会让CPU从用户态切换成内核态的,效率比较低,而且一般线程对同步代码块的操作时间是比较短的,所以我们可以让后来的线程先自旋几次(默认10次,可以通过JVM参数设置)尝试获取锁,这样效率可能会更高

但是自旋的次数我们很难判断,因此在JDK6中引入了适应性自旋锁

适应性自旋锁的自旋时间不再固定(默认10次),而是由上一个线程的自选时间和最终的状态决定,如果上一个线程通过自旋成功获得锁,那么下一个线程的也会自旋获取锁,且时间更长,因为JVM会认为自旋是有用的,反之则相反

锁消除和锁粗化

锁消除:编译器在进行编译的时候如果发现有代码的同步代码块区域根本不可能出现竞争就会帮我们去掉synchronized关键字,进行锁消除(如:new StringBuffer.addend(“123”)这个代码)

锁粗化:JVM如果监测到有一连串小操作都是使用的同一个锁对象,那它就会将同步代码块的范围放大,这样本来多次的加锁释放锁就可以简化成一次

★抽象的队列同步器AQS(AbstractQueueSynchronizer)

解释:

是用来构建锁或者其他同步器组件的重量级基础框架及整个JUC体系的基石,通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int变量表示持有锁的状态。我们JUC的工具类:CountDownLatch,Semaphore等底层都是AQS
在这里插入图片描述
AQS = state变量(0表示没锁,大于等于1表示有锁) + CLH队列

我们的的资源个数有限因此需要加锁,加锁就会导致阻塞,阻塞就需要排队,排队就需要队列(CLH队列)
整个队列的管理我们是通过AQS来进行的(通知,唤醒,等待等操作)

AQS使用一个volatile的int类型成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对state值的修改。

用户线程和守护线程?

​ 如果JVM中所有的线程都是守护线程,那么JVM就会退出,进而守护线程也会退出。如果JVM中还存在用户线程,那么JVM就会一直存活,不会退出。

​ 守护线程是依赖于用户线程,用户线程退出了,守护线程也就会退出。而用户线程是独立存在的,不会因为其他用户线程退出而退出。典型的守护线程如:垃圾回收线程。

​ 默认情况下启动的线程是用户线程,通过setDaemon(true)将线程设置成守护线程,这个函数务必在线程启动前进行调用,否则会报java.lang.IllegalThreadStateException异常,且启动的线程无法变成守护线程,而是用户线程。

★获得多线程的方式有几种?

传统的方法是继承Thread类和实现Runnable接口,在java5后又新增了Callable接口和java线程池获得

Callable和Runnable接口的区别?

两者都是函数式接口,但是Runnable的方法是run(),Callable中的方法是call()
call方法又返回值,run方法没有
call方法会抛异常,run不会

FutureTask类

想用Callable创建线程需要用到FutureTask类,new FutureTask(new Callable(){})
①Thread构造方法不能直接传Callable只能传Runnable,
②因此使用Callable接口创建线程时需要先用Callable创建一个FutureTask,
③再用FutureTask创建Thread,因为FutherTask类实现了RunnableFuture接口
④相当于需要用FutureTask做一个中间类
FutureTask一般是用来处理较为复杂的行为,一般是有超时时间的get方法并放到主线程最后或者用isDone方法判断是否完成再get结果用CAS自旋代替阻塞,且FutureTask只会计算一次,想计算多次只能new多个,实现多个功能

FutureTask的升级CompletableFuture类

有whenComplete方法,不需要阻塞和轮询

FutureTask即使使用CAS自旋还是用起来不舒服,我们希望不是我们自己去看任务是否完成,而是任务完成后你过来通知我。
在这里插入图片描述
CompletableFuture实现了CompletableStage和Future接口,能实现异步编排
CompletableStage表示异步处理的某一阶段,通过这个接口我们可以实现多个线程共同工作完成一个完整的任务
CompletableFuture的四个静态方法:runAsync和supplyAsync
在这里插入图片描述
(1)runAsync需要的是Runnable接口,没有返回值,get到的是null

(2)supplyAsync(常用)需要的是Supplier,有返回值,get能拿到返回值
因为这几个方法都会返回CompletableFuture因此我们可以继续调CompletableFuture的方法继续处理(流式编程/链式调度),实现异步编排,有点像我们之前的stream流

(3)join和get对比
join和get方法是一样的,区别就是join不会抛出异常

(4) CompletableFuture链式编程的方法
在这里插入图片描述
还有其他的一些方法,如:我们可以用CompletableFuture起两个多线程,然后用thenCombine进行聚合
在这里插入图片描述

案例精讲-从电商网站的比价需求开始

(1)案例说明

​ ①同一款产品,同时搜索出同款产品在各大电商的售价(即同一本书在淘宝,京东等的售价)
​ ②同一款产品,同时搜索出本产品在某一个电商平台下,各个入驻门店的售价是多少 (同一本书在京东下的各个门点的售价)

(2)用链式编程实现

①用stream中的map方法进行依次处理,map方法中用supplyAsync方法并指定线程池进行异步处理。

JUC的常用工具类?

(1)CountDownLatch:减少计数,做的是减法,在创建的时候指定数值,然后用conutDownLatch.await()将线程挂起,每有一个其他线程完成相应的任务就用countDown()方法将数值减一,直到减到0被挂起的线程才往下执行。例子:想让主线程等其他线程执行完后再结束。

(2)CyclicBarrier:循环栅栏,反过来做的是加法,同样是需要再在创建的指定数值,这个数值表示需要的线程数。
构造方法还需要一个Runnable接口参数,每有一个线程完成就用cyclicBarrier.await(),让这个线程等待,直到数值指定个数的线程都等待(凑齐)了就执行run方法中的方法体。例子:七龙珠。

(3)Semaphore:信号灯,这个也是在创建的时候需要指定一个数值,这个数值表示,资源的个数,而且还可以指定是否为公平锁(默认是非公平的)。

​ 有线程抢到资源就用acquire()让资源数-1,释放就用release()让资源数+1。例子:停车位,以前我们的高并发都是多个线程抢一个资源,但是实际是多个线程抢多个资源,这种情况就可以用Semaphore,而且当数值指定为1是Semaphore会退化成synchronized,当普通的锁来用。

JUC的读写锁和邮戳锁

(1)读写锁

​ 读锁(共享锁):在一个数据上可以添加多个读锁,可以多个线程同时读,但是如果一个线程想要更改数据,需要等待其他线程读完后才能写。
​ 写锁(独占锁):在一个数据上只能添加一个写锁,加了写锁的数据其他线程不能读也不能写
​ 这两个锁都有可能出现死锁,避免死锁的办法:加读锁的就别写,加写锁的就只写自己锁起来的那一部分。
​ JUC有ReadWriteLock接口,其实现类为ReentrantReadWriteLock类,这个类中有ReadLock()和WriteLock()方法能获得读锁和写锁,用法与Lock相同

(2)邮戳锁

读锁

★阻塞队列(BlockingQueue接口,用来实现线程池)

​ 当队列是空的,从队列中获取元素的操作会被阻塞
​ 当队列是满的,向队列中加入元素的操作会被阻塞
​ 三个重要的实现类:
​ ArrayBlockingQueue:由数组结构组成的有界(new的时候指定长度)阻塞队列
​ LinkedBlockingQueue:由链表结构组成的有界(默认大小为Integer.MAX_VALUE,相当于无界)阻塞队列
​ SynchronousQueue:不存储元素的阻塞队列,即单个元素的阻塞队列,生产一个消费一个,是非公平锁
在这里插入图片描述
使用场景
生产者消费者模式
线程池
消息中间件

★线程池

1.工作过程

在这里插入图片描述
(1)在刚创建线程池时,线程池中的线程数为0(懒加载)
(2)当调用了execute(Runnable )方法添加了一个请求后,线程池会做如下判断:
①如果正在运行的线程数小于corePoolSize,那么会马上创建核心线程处理这个请求
②如果正在运行的线程数大于或等于corePoolSize,那么会将这个请求放入等待队列
③如果等待队列已满,而当前正在运行的线程数小于maximumPoolSize,那么就会创建非核心线程立刻执行这个请求(注意是执行这个请求,不是执行队列中的请求,执行完这个请求才会去处理队列中的请求)
④如果队列满了且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会启用拒绝策略
(3)如果非核心线程空闲时间达到我们设定的值了就销毁

2.特点:线程复用,控制最大并发数,管理线程

(1)降低资源消耗,不需要不停的创建和销毁
(2)提高相应速度
(3)提高线程的可管理性

3.线程池的创建及底层原理

Java的API提供了线程池的工具类Exectuors,它有三个创建线程池的方法
newFixedThreadPool(int count):一池多线程
newSingleThreadExeutor():一池一线程
newCachedThreadPool():池中线程可扩展
这三个方法的底层都是 return new ThreadPoolExecutor(…),但这三个线程池都有问题,所以这三个方法我们都不用,我们一般根据业务需求自己创建ThreadPoolExecutor,因此这个类很重要
FixedThreadPool和SingleThreadPool允许的等待队列的长度是Integer.MAX_VALUE,会导致大量请求堆积,OOM
CachedThreadPool允许创建的线程数为Integer.MAX_VALUE,同样会导致OOM

★4. ThreadPoolExecutor中的七个参数

​ corePoolSize:线程池中的常驻核心线程数,不是在创建线程池时创建,是在调用execute方法时调用的,一旦创建了就不会销毁了
​ maximumPoolSize:常驻核心线程数+非核心线程数
​ keepAliveTime:非核心线程能存活的空闲时间
​ unit:keepAliveTime的单位
​ workQueue:等待队列,就是之前我们说的堵塞队列(BlockingLock)
​ threadFactory:线程工厂,一般默认ThreadPoolExecutor.defaultFactory()
​ handler:拒绝策略,4种

5.ThreadPoolExecutor的四种拒绝策略(new ThreadPoolExecutor.AbortPolicy())

​ AbortPolicy(默认):直接抛出RejectedExecutionException异常阻止系统正常运行
​ CallerRunsPolicy:从哪来,回哪去,将请求弹回调用线程,让他自己处理
​ DiscardOldestPolicy:取消等待时间最长的请求(有些请求是有时效性的)
​ DiscardPolicy:取消新来的请求

6.如何合理配置线程池的线程数

CPU 密集型
①CPU 密集的意思是该任务需要大量的运算,而没有阻塞,CPU 一直全速运行。
②CPU 密集型任务尽可能的少的线程数量,一般为 CPU 核数 + 1 个线程的线程池。
IO 密集型
①由于 IO 密集型任务线程并不是一直在执行任务,可以多分配一点线程数,如 CPU * 2 。
②也可以使用公式:CPU 核数 / (1 - 阻塞系数);其中阻塞系数在 0.8 ~ 0.9 之间。

7.线程池的应用场景

CompletableFuture类的静态方法创建CompletableFuture时可以传入线程池

Redis

详情请看CSDN,谢谢

Nginx

1.Nginx的主要功能?

(1)反向代理(为服务器端加代理,不像Ribbon是正向代理,给客户端加代理)
(2)负载均衡(根据算法平衡各个服务器的负载,使各个服务器压力更合理)
(3)动静分离(把对动态资源的请求和对静态资源的请求分开,这样可以将静态资源提取出来单独成一个文件服务器,方便管理,也减小服务器的压力)

2.负载均衡的策略?

负载均衡策略

轮询 默认方式
weight 权重方式
ip_hash 依据ip分配方式
least_conn 最少连接方式
fair(第三方) 响应时间方式
url_hash(第三方) 依据URL分配方式

(1)轮询
默认的负载均衡默认策略。每个请求会按时间顺序逐一分配到不同的后端服务器
(2)weight
权重方式,在轮询策略的基础上指定轮询的几率
(3)ip_hash
指定负载均衡器按照基于客户端IP的分配方式,这个方法确保了相同的客户端的请求一直发送到相同的服务器,以保证session会话。这样每个访客都固定访问一个后端服务器,可以解决session不能跨服务器的问题。
但是也有可能会导致多个请求都被分去同一个服务器,而其他服务器很闲的情况
(4)least_conn
把请求转发给连接数较少的后端服务器, 轮询算法是把请求平均的转发给各个后端,使它们的负载大致相同;但是,有些请求占用的时间很长,会导致其所在的后端负载较高。这种情况下,least_conn这种方式就可以达到更好的负载均衡效果。

SpringMVC

1.SpringMVC的执行流程?

(1)收到客户端的请求,首先由DispatcherServlet进行处理,如果DispatcherServlet不能处理,则看是否有mvc:default-servlet-handler标签,如果有则将请求交给其处理,如果defaultServlet也处理不了那就报错,返回405(DispatcherServlet会报No mapping request的错,defaultServlet不会报)
(2)如果DispatcherServlet能处理:
①先用HandlerMapping(存储了所有请求与处理器的映射关系)获取HandlerExecutionchain(只存了一个请求与处理器的映射关系)对象
②获取HandlerAdapter对象,该对象能调用处理方法
③调用处理器的方法进行处理之前先调用拦截器中的PreHandler方法,如果返回true才继续往下执行
④用HandlerAdapter执行处理器方法,获得ModelAndView对象
⑤如果有异常则执行afterCompletion方法后结束
⑥如果没有异常,则通过之前的ModelAndView对象获取View对象
⑦跳转页面
⑧调用afterCompletion方法,结束

Spring

1.Spring中Bean的生命周期?

(1)狭义的生命周期
class->new 对象->填充属性->Aware->对象初始化->AOP->单例池(ConcurrentHashMap<BeanName,对象>)
需要注意的是我们开启了AOP之后,放入单例池中的就不是new出来的这个对象了,而是AOP生成的动态代理对象

(2)广义的生命周期
class->BeanDefinition->BeanFactory组件完成->BeanFactoryPostProcessor(进行后置处理)->new 对象->填充属性->Aware->对象初始化->AOP->单例池

BeanDefinition中存放了懒加载,Scope等属性,根据class上的注解生成的
整体的过程是,我们定义class类,根据类上的注解会生成BeanDefintion对象,然后Spring回去扫描将BeanFactory组建出来,最后才是创建对象等狭义的生命周期

SpringBoot

1.SpringBoot的自动装配原理?

(1)SpringBoot实现自动装配的基础是它的内部已经创建了很多配置类,我们只需要把这些配置类的starter模块依赖导入,这些配置类就会生效(例如:starter-web),我们一般都是使用第三方的starter,但是有少部分情况我们需要自定义starter
(2)自动装配的注解:@SpringBootApplication(主类),
包含了@SpringBootConfiguration和@EnableAutoConfiguration(会去自动查找,引入配置类)
当然有时候我们会单独创建配置类:@SpringBootConfiguration(表明该类是配置类)

举报

相关推荐

0 条评论