1.什么是java虚拟机
JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。引入Java语言虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
2. jvm结构
线程共享:方法区、堆
线程私有:java栈、本地方法栈、程序计数器
Java堆(Heap):
是Java虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。线程共享,此内存区域的唯一目的就是存放对象实例。
方法区(Method Area):
线程共享,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
程序计数器(Program Counter Register):
线程私有,是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。
JVM栈(JVM Stacks):
线程私有,生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
本地方法栈(Native Method Stacks):
线程私有,与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
PS: Native Method就是一个java调用非java代码的接口
简要概括:
-
堆【线程共享】 :对象实例
-
方法区【线程共享】 :类信息、常量、静态变量、编译后的代码
-
栈 :局部变量、操作栈、动态链接、方法出口
-
程序计数器 :字节码的行号指示器
-
本地方法栈 :JVM使用到的native方法服务(java调用非java代码的接口)
3. jvm调优
3.1 常见问题:
(1)GC频繁(2)CPU负载过高 (3)OOM (4)内存泄漏 (5)死锁 (6)程序响应时间较长
使用JDK自带的命令行调优工具
常见命令: jps (查看正在运行的java进程)
jstat(查看制定进程的jvm统计信息) jstat -gc (查看堆各分区大小,gc次数和时长)
jiinfo(实时查看和修改制定进程的jvm配置参数)
jstack(打印制定进程的此刻的线程快照) 定位长时间停顿的原因,如死锁,等待资源,阻塞
使用JDK自带的可视化工具:
(1) jconsole
(2)Visual VM:可以监视应用程序的CPU,GC、堆、方法区、线程快照、查看JVM进程、JVM参数、系统属性。
3.2 使用MAT分析堆转储文件
MAT简介:MAT可以解析Heap Dump(堆转储)文件dump.hprof,查看GC Roots、引用链、对象信息、类信息、线程信息。可以快速生成内存泄漏报表。
MAT(Memory Analyzer Tool)工具是一款功能强大的 Java 堆内存分析器。可以用于查找内存泄漏以及查看内存消耗情况。
MAT 可以分析 heap dump 文件。在进行内存分析时,只要获得了反映当前设备内存映像的 hprof 文件,通过 MAT 打开就可以直观地看到当前的内存信息。一般说来,这些内存信息包含:
所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值
所有的类信息,包括 classloader、类名称、父类、静态变量等
GCRoot 到所有的这些对象的引用路径
线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)
3.3调优JVM参数
调优JVM参数主要关注停顿时间和吞吐量,两者不可兼得,提高吞吐量会拉长停顿时间。
//调整内存大小 -XX:MetaspaceSize=128m(元空间默认大小) -XX:MaxMetaspaceSize=128m(元空间最大大小) -Xms1024m(堆最大大小) -Xmx1024m(堆默认大小) -Xmn256m(新生代大小) -Xss256k(栈最大深度大小) //调整内存比例 //伊甸园:幸存区 -XX:SurvivorRatio=8(伊甸园:幸存区=8:2) //新生代和老年代的占比 -XX:NewRatio=4 //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2 //修改垃圾回收器 //设置Serial垃圾收集器(新生代) //-XX:+UseSerialGC //设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器 //-XX:+UseParallelOldGC //CMS垃圾收集器(老年代) //-XX:+UseConcMarkSweepGC //设置G1垃圾收集器 -XX:+UseG1GC //GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间 -XX:MaxGCPauseMillis //进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,JDK8默认值15,JDK9默认值7 -XX:InitialTenuringThreshold=7 //新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。 -XX:PretenureSizeThreshold=1000000 //使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小 -XX:CMSInitiatingOccupancyFraction //G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65% -XX:G1MixedGCLiveThresholdPercent=65 //Heap Dump(堆转储)文件 //当发生OutOfMemoryError错误时,自动生成堆转储文件。 -XX:+HeapDumpOnOutOfMemoryError //错误输出地址 -XX:HeapDumpPath=/Users/a123/IdeaProjects/java-test/logs/dump.hprof //GC日志 -XX:+PrintGCDetails(打印详细GC日志) -XX:+PrintGCTimeStamps:打印GC时间戳(以基准时间的形式) -XX:+PrintGCDateStamps:打印GC时间戳(以日期格式) -Xlog:gc:(打印gc日志地址)
减少停顿时间:MaxGCPauseMillis
STW:Stop The World,暂停其他所有工作线程直到收集结束。垃圾收集器做垃圾回收中断应用执行的时间。可以通过-XX:MaxGCPauseMillis参数进行设置,以毫秒为单位,至少大于1。
//GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间
-XX:MaxGCPauseMillis=10
提高吞吐量:GCTimeRatio
吞吐量=运行时长/(运行时长+GC时长)。通过-XX:GCTimeRatio=n参数可以设置吞吐量,99代表吞吐量为99%, 一般吞吐量不能低于95%。
示例:-XX:GCTimeRatio=99
调整堆内存大小
根据程序运行时老年代存活对象大小(记为x)进行调整,整个堆内存大小设置为X的3~4倍。年轻代占堆内存的3/8。
-Xms:初始堆内存大小。默认:物理内存小于192MB时,默认为物理内存的1/2;物理内存大192MB且小于128GB时,默认为物理内存的1/4;物理内存大于等于128GB时,都为32GB。
-Xmx:最大堆内存大小,建议保持和初始堆内存大小一样。因为从初始堆到最大堆的过程会有一定的性能开销,而且现在内存不是稀缺资源。
-Xmn:年轻代大小。JDK官方建议年轻代占整个堆大小空间的3/8左右。
调整堆内存比例
调整伊甸园区和幸存区比例、新生代和老年代比例。Young GC频繁时,我们可以提高新生代在堆内存中的比例、提高伊甸园区在新生代的比例,令新生代不那么快被填满。
默认情况,伊甸园区:S0:S1=8:1:1,新生代:老年代=1:2。
示例:
//调整内存比例
//伊甸园:幸存区
-XX:SurvivorRatio=8(伊甸园:幸存区=8:2)
//新生代和老年代的占比
-XX:NewRatio=4 //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2
调整升老年代年龄
JDK8时Young GC默认把15岁的对象移动到老年代。JDK9默认值改为7。当Full GC频繁时,我们提高升老年龄,让年轻代的对象多在年轻代待一会,从而降低Full GC频率。
//进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,JDK8默认值15,JDK9默认值7
-XX:InitialTenuringThreshold=7
扩展:一次完整的GC流程
1.首先,任何新对象都分配到 eden 空间。两个幸存者空间开始时都是空的。2.当 eden 空间填满时,将触发一个Minor GC(年轻代的垃圾回收,也称为Young GC),删除所有未引用的对象,大对象(需要大量连续内存空间的Java对象,如那种很长的字符串)直接进入老年代。
3.所有被引用的对象作为存活对象,将移动到第一个幸存者空间S0,并标记年龄为1,即经历过一次Minor GC。之后每经过一次Minor GC,年龄+1。GC分代年龄存储在对象头的Mark Word里。
4.当 eden 空间再次被填满时,会执行第二次Minor GC,将Eden和S0区中所有垃圾对象清除,并将存活对象复制到S1并年龄加1,此时S0变为空。
5.如此反复在S0和S1之间切换几次之后,还存活的年龄等于15的对象(JDK8默认15,JDK9默认7,-XX:InitialTenuringThreshold=7)在下一次Minor GC时将放到老年代中。
6.当老年代满了时会触发Major GC(也称为Full GC),Major GC 清理整个堆 – 包括年轻代和老年代。
![](https://img2024.cnblogs.com/blog/883095/202408/883095-20240809202226974-688857411.png)
调整大对象阈值
Young GC时大对象会不顾年龄直接移动到老年代。当Full GC频繁时,我们关闭或提高大对象阈值,让老年代更迟填满。默认是0,即大对象不会直接在YGC时移到老年代。
//新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
-XX:PretenureSizeThreshold=1000000
调整GC的触发条件
CMS调整老年代触发回收比例
CMS的并发标记和并发清除阶段是用户线程和回收线程并发执行,如果老年代满了再回收会导致用户线程被强制暂停。所以我们修改回收条件为老年代的60%,保证回收时预留足够空间放新对象。CMS默认是老年代68%时触发回收机制。//使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
-XX:CMSInitiatingOccupancyFraction
G1调整存活阈值
过存活阈值的Region,其内对象会被混合回收到老年代。G1回收时也要预留空间给新对象。存活阈值默认85%,即当一个内存区块中存活对象所占比例超过 85% 时,这些对象就会通过 Mixed GC 内存整理并晋升至老年代内存区域。//G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%
-XX:G1MixedGCLiveThresholdPercent=65
排查大对象
使用MAT分析堆转储日志中的大对象,看是否合理。大对象会直接进入老年代,导致Full GC频繁。内存溢出
溢出原因:本地直接内存溢出:本地直接内存设的太小导致溢出。设置直接内存最大值-XX:MaxDirectMemorySize,若不指定则默认与Java堆最大值一致。
虚拟机栈和本地方法栈溢出:如果虚拟机的栈内存允许动态扩展,并且方法递归层数太深时,导致扩展栈容量时无法申请到足够内存。
方法区溢出:运行时生成大量动态类时会内存溢出。
CGlib动态代理:CGlib动态代理产生大量类填满了整个方法区(方法区存常量池、类信息、方法信息),直到溢出。CGlib动态代理是在内存中构建子类对象实现对目标对象功能扩展,如果enhancer.setUseCache(false);,即关闭用户缓存,那么每次创建代理对象都是一个新的实例,创建过多就会导致方法区溢出。注意JDK动态代理不会导致方法区溢出。
JSP:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)。
堆溢出:
死循环创建过多对象;
集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
内存中加载的数据量过于庞大,如一次从数据库取出的数据集太大、第三方接口接口传输的大对象、接收的MQ消息太大;
Tomcat参数设置不当导致OOM:Tomcat会给每个线程创建两个默认4M大小的缓冲区,高并发情况下会导致缓冲区创建过多,导致OOM。
程序计数器不会内存溢出。
内存泄漏
不再使用的对象仍然被引用,导致GC无法回收;解决办法:
- 牢记内存泄漏的场景,当一个对象不会被使用时,给它的所有引用赋值null,堤防静态容器,记得关闭连接、别用逻辑删除,只要用到了引用,变量的作用域要合理。
- 使用java.lang.ref包的弱引用WeakReference,下次垃圾收集器工作时被回收。
- 检查代码
4.锁的相关问题
4.1 乐观锁和悲观锁
悲观锁顾名思义是从悲观的角度去思考问题,解决问题。它总是会假设当前情况是最坏的情况,在每次去拿数据的时候,都会认为数据会被别人改变,因此在每次进行拿数据操作的时候都会加锁,如此一来,如果此时有别人也来拿这个数据的时候就会阻塞知道它拿到锁。在Java中,Synchronized和ReentrantLock等独占锁的实现机制就是基于悲观锁思想。在数据库中也经常用到这种锁机制,如行锁,表锁,读写锁等,都是在操作之前先上锁,保证共享资源只能给一个操作(一个线程)使用。
由于悲观锁的频繁加锁,因此导致了一些问题的出现:比如在多线程竞争下,频繁加锁、释放锁导致频繁的上下文切换和调度延时,一个线程持有锁会导致其他线程进入阻塞状态,从而引起性能问题。
乐观锁从字面上看是从积极,乐观的角度去看待问题,因此它认为数据一般不会产生冲突,因此一般不加锁,当数据进行提交更新时,才会真正对数据是否产生冲突进行监测。如果发生冲突,就返回给用户错误信息,由用户来决定如何去做,主要有两个步骤:冲突检测和数据更新。
4.2 CAS
CAS(compare and set),比较和更新。CAS是乐观锁的技术实现,当多个线程尝试使用CAS同时来更新同一个变量,只有一个线程能够更新变量值,而其他的线程都会失败,失败的线程并不会被挂起,告知这次竞争失败,可以再次尝试。
CAS操作包含三个操作数:
-
需要读写的内存位置(V)
-
需要比较的预期原值(A)
-
拟写入的新值(B)
CAS中的ABA问题:
假如如下事件序列:
1、线程1从内次位置V来获取值A
2、线程2从内存位置V获取A
3、线程2进行一些操作,将B写入到V
4、线程2将A写入位置V
5、线程1进行CAS操作,发现位置V的值任然为A,操作成功了
6、线程1尽管CAS操作成功了,该过程有可能出现问题,对于线程1,线程2做的处理就可能丢失了
举例说明:一个链表ABA的例子
1、现有一个用单向链表实现的堆栈,栈顶为A。这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:
1head.compareAndSet(A,B);
2、在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再依次入栈D、C、A,而对象B此时处于游离状态。
3、此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B。但实际上B.next为null,此时堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,C、D被丢掉了。
![](https://img2024.cnblogs.com/blog/883095/202408/883095-20240809205912350-86452376.png)
ABA问题解决思路就是使用版本号,在变量前面追加版本号,每次对变量你进行更新的时候对版本进行加1,对于A->B->A 就会变成1A ->2B->3A
使用CAS会引起的问题
ABA问题ABA问题可以使用版本号解决
循环时间长开销大
自旋CAS如果长时间不成功,CPU带来非常大的执行开销,需要考虑长时间循环问题,给每个线程循环给定循环次数阈值,让当前线程释放CPU的使用权,进入阻塞中
只能保证一个共享变量的原子操作
4.3 synchronized锁优化过程
DK1.5之前, Synchronized称之为“重量级锁”,对该做了各种所有,分别为偏向锁、轻量级锁、重量级锁说到 synchronized 加锁原理与Java对象在内存中的布局有很大关系, Java 对象内存布局如下:
如上图所示,在创建一个对象后,在 JVM 虚拟机( HotSpot )中,对象在 Java 内存中的存储布局 可分为三块:
对象头区域
存放锁信息,对象年龄等信息
实例数据区域
此处存储的是对象真正有效的信息,比如对象中所有字段的内容
对齐填充区域
JVM 的实现 HostSpot 规定对象的起始地址必须是 8 字节的整数倍,换句话来说,现在 64 位的 OS 往外读取数据的时候一次性读取 64bit 整数倍的数据,也就是 8 个字节,所以 HotSpot 为了高效读取对象,就做了"对齐",如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。
synchronized用的锁是存在Java对象头里的,如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如下图:
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下图所示:
在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
偏向锁
偏向锁的操作根本没有去找操作系统, 每个对象都有对象头,看看这个account对象的所谓“对象头”,其中有个叫做Mark Word:里边有几个标识位,还有其他数据。
JVM使用CAS操作把线程ID记录到了这个Mark Word当中,修改了标识位,当前线程就拥有这把锁了
可以看出:JVM不用和操作系统协商设置Mutex,它只记录下线程ID,就表示当前线程拥有这把锁了,不用操作系统介入
这时线程获得了锁,可以执行synchronized修饰的代码块。
当线程再次执行到这个synchronized的时候,JVM通过锁对象account的Mark Word判断:“当前线程ID还在,还持有着这个对象的锁,就可以继续进入临界区执行
这就是偏向锁,在没有别的线程竞争的时候,一直偏向当前线程,当前线程可以一直执行
轻量级锁
继续沿着偏向锁思路研究
另一个线程0x3704也要进入这个代码块执行,但是锁对象account 保存的是当前线程ID,他是没法进入临界区的。
这时也不需要和操作系统交流,JVM可以对偏向锁升级一下,变成一个轻量级的锁。
JVM把锁对象account恢复成无锁状态,在当前两线程的栈帧中各自分配了一个空间,叫做Lock Record,把锁对象account的Mark Word在俩线程的栈帧中各自复制了一份,叫做Displaced Mark Word
然后当前线程的Lock Record的地址使用CAS放到了Mark Word当中,并且把锁标志位改为00, 这意味着当前线程也已经获得了这个轻量级的锁了,可以继续进入临界区执行。
0x3704线程没有获得锁,但不阻塞,JVM让他自旋几次,等待一会儿。等当前退出临界区,释放锁的时候,需要把这个Displaced markd word 使用CAS复制回去。接下来他就可以加锁了。
两线程交替着进入临界区,执行这段代码,相安无事,很少出现真正的竞争。
即使是出现了竞争,想获得锁的线程只要自旋几次,等待一会儿,锁就可能释放了。
很明显,如果没有竞争或者轻度的竞争,轻量级锁仅仅使用CAS操作和Lock record就避免了重量级互斥锁的开销
重量级锁
再次分析:轻量级锁运行时,一线程0x3704 正在持有锁。另一线程自旋了好多次,0x3704还是没释放锁。 这时候JVM考虑自旋次数太多了浪费CPU。接则升级为重量级锁!
重量级锁需要操作系统的介入,依赖操作系统底层的Mutex Lock。
JVM创建了一个monitor 对象,把这个对象的地址更新到了Mark word当中。
在持有锁运行,而另一线程则切换进程状态至:阻塞
4.4 ReentrantLock锁原理
ReentrantLock内部有三个内部类Sync(同步器),NonfairSync(非公平锁),FairSync(公平锁),另外实现了Lock接口。
Sync(同步器)继承了AQS类(AbstractQueuedSynchronizer);
NonfairSync(非公平锁)继承了 Sync类;
FairSync(公平锁)继承了Sync类。
所以ReentrantLock是基于AQS实现的,AQS是java并发包的基础类。里面维护者一个同步状态state,和一个同步队列FIFO以及操作state和同步队列的方法。
如果有一个线程过来尝试用ReentrantLock的lock()方法进行加锁,会发生什么事情呢?
AQS对象内部有一个核心的变量叫做state,是int类型的并且加了volatile关键字,代表了加锁的状态。初始状态下,这个state的值是0。
AQS内部还有一个关键变量ExclusiveOwnerThread,用来记录当前加锁的是哪个线程,初始化状态下,这个变量是null
默认非公平锁NonfairSync实现加锁:刚开始线程1去调用ReentrantLock的lock()方法(这个lock方法是重写Lock接口的方法)加锁,通过底层用到Sync、AQS的类,然后通过CAS操作将state从0变为1,如果之前没人加过锁,刚开始CAS操作加锁成功,因为一开始的state为0,加锁成功后将ExclusiveOwnerThread置为线程1
从上面发现ReentrantLock借助内部类AQS实现加锁,自己只是AQS的最外层API,AQS作为J.U.C并发包的核心类,里面有关于volatile关键字的state,ExclusiveOwnerThread加锁状态,还有FIFO的阻塞队列(同步队列),相当于monitor的EntryList。内核中的锁机制实现都是依赖AQS组件的。
可重入锁就是线程1每次都给同一对象加锁,每次加锁时都判断当前线程是不是自己,是自己,那每次锁重入时,state的值就累加1,ExclusiveOwnerThread还是当前线程1。
接着,如果线程1加锁了之后,线程2跑过来加锁会怎么样?
线程2跑过来一下看到,state的值不是0啊?所以CAS操作将state从0变为1的过程会失败,因为state的值当前为1,说明已经有人加锁了!(当前ExclusiveOwnerThread为线程1)
接着线程2会看一下,是不是自己之前加的锁啊?当然不是了,“加锁线程”这个变量明确记录了是线程1占用了这个锁,所以线程2此时就是加锁失败。
当线程1一直没有主动释放锁的话,线程2加锁失败后,会再经历4次加锁失败,最后进入AQS的同步队列FIFO进行阻塞(挂起),等待被唤醒或者被打断再次重新获取资源竞争锁,反正一唤醒就CAS操作竞争锁,搞不好哪一次就竞争成功加上了锁。
接着,线程1在执行完自己的业务逻辑代码之后,就会释放锁!他释放锁的过程非常的简单,就是将AQS内的state变量的值递减1,如果state值为0,则彻底释放锁,会将“加锁线程”ExclusiveOwnerThread变量也设置为null!
接下来,会从同步队列(阻塞队列)的队头唤醒线程2重新尝试加锁。这时还是用CAS操作将state从0变为1,此时就会成功,成功之后代表加锁成功,就会将state设置为1。此外,还要把“加锁线程”设置为线程2自己,同时线程2自己就从阻塞队列中出队了。
记住:FIFO队列是满足先进先出的原则的,每次拿锁时,老二线程会先获取锁(因为head头结点是哨兵结点,哑结点,不放线程,只能老二优先级最高)。
但是体现非公平锁的机制是在同步队列老二线程准备获取锁的同时,被其他非同步队列的线程抢先获得锁了,这时老二线程又会被阻塞住。(非公平就是不管AQS队列)。
而公平锁就是先检查 AQS 队列中是否有前驱节点, 没有才去竞争。
ReentrantLock就是使用AQS而实现的一把锁,它实现了可重入锁,公平锁和非公平锁。它有一个内部类用作同步器是Sync,Sync是继承了AQS的一个子类,并且公平锁和非公平锁是继承了Sync的两个子类。ReentrantLock的原理是:假设有一个线程A来尝试获取锁,它会先CAS修改state的值,从0修改到1,如果修改成功,那就说明获取锁成功,设置加锁线程为当前线程。如果此时又有一个线程B来尝试获取锁,那么它也会CAS修改state的值,从0修改到1,因为线程A已经修改了state的值,那么线程B就会修改失败,然后他会判断一下加锁线程是否为自己本身线程,如果是自己本身线程的话它就会将state的值直接加1,这是为了实现锁的可重入。如果加锁线程不是当前线程的话,那么就会将它生成一个Node节点,加入到等待队列的队尾,直到什么时候线程A释放了锁它会唤醒同步队列队头的线程。这里还要分为公平锁和非公平锁,默认为非公平锁,公平锁和非公平锁无非就差了一步。如果是公平锁,此时又有外来线程尝试获取锁,它会首先判断一下同步队列是否有前驱节点,如果有前驱节点,就说明同步队列不为空,有等待获取锁的线程,那么它就不会去同步队列中抢占cpu资源。如果是非公平锁的话,它就不会判断同步队列是否有第一个节点,它会直接前往同步对列中去抢占cpu资源。
4.5 死锁问题
Java死锁是指两个或多个线程在互相请求对方占用的资源时处于等待状态,导致程序无法继续执行的现象。
死锁发生的原理是由于每个线程都持有一个资源并且同时等待另一个资源,这样就会形成一种僵局,没有任何一个线程能够释放其持有的资源,也无法获得它所需的资源。这样的情况下,程序就会停止响应,形成死锁。解决死锁通常需要使用一些技术手段,如避免嵌套锁、使用可重入锁、资源分配策略等。
Java死锁产生的原因
-
资源竞争
资源竞争是指多个线程同时竞争有限的资源,例如共享内存、文件、数据库连接等。如果这些线程在竞争资源时,出现了相互等待对方释放资源的情况,就可能导致死锁。例如,线程A持有锁L1, 但需要锁L2才能继续执行,而线程B持有锁L2,但需要锁L1才能继续执行,这样就会发生死锁。
-
线程调度问题
线程调度问题是指操作系统或虚拟机在调度线程时出现问题,例如某个线程长时间占用CPU资源,导致其他线程无法得到执行机会。这样就可能导致等待资源的线程被无限期地挂起,从而出现死锁。例如,线程A在执行耗时任务时一直占用CPU资源,而线程B在等待A释放共享资源,但由于A一直没有释放,B也无法继续执行,就会出现死锁。
避免死锁方法:
- 锁定顺序:确保所有线程都按照相同的顺序锁定资源,以避免死锁。例如,可以要求线程 1 和线程 2 都首先获取 lockA,然后再获取 lockB。
- 缩小锁作用范围:使用最小化锁定区域的技术,即只在必要时才锁定资源。例如,可以分别处理 lockA 和 lockB 来最小化锁定区域,而不是一次锁定两个资源。
- 避免循环依赖:通过避免对模块之间的循环依赖关系来消除死锁。
-
使用带超时参数的锁:在使用 synchronized 锁定对象时,如果无法获取锁,则应该及时退出,并稍微等待一段时间后重新尝试获取锁。同时需要给获取锁操作设置一些超时时间,在一定时间内无法获取到锁时,也需要及时退出。
5.接口和抽象类的区别
相同点
(1)都不能被实例化 (2)接口的实现类或抽象类的子类都只有实现了接口或抽象类中的方法后才能实例化。
不同点
(1)接口只有定义,不能有方法的实现,java 1.8中可以定义default方法体,而抽象类可以有定义与实现,方法可在抽象类中实现。
(2)实现接口的关键字为implements,继承抽象类的关键字为extends。一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承。
(3)接口强调特定功能的实现,而抽象类强调所属关系。
(4)接口成员变量默认为public static final,必须赋初值,不能被修改;其所有的成员方法都是public、abstract的。抽象类中成员变量默认default,可在子类中被重新定义,也可被重新赋值;抽象方法被abstract修饰,不能被private、static、synchronized和native等修饰,必须以分号结尾,不带花括号。
1.抽象类要被子类继承,接口要被类实现。
2.接口只能做方法声明,抽象类中可以作方法声明,也可以做方法实现。
3.接口里定义的变量只能是公共的静态的常量,抽象类中的变量是普通变量。
4.接口是设计的结果,抽象类是重构的结果。
5.抽象类和接口都是用来抽象具体对象的,但是接口的抽象级别最高。
6.象类可以有具体的方法和属性,接口只能有抽象方法和不可变常量。
7.抽象类主要用来抽象类别,接口主要用来抽象功能。
6.线程池工作原理
6.1 概念
线程池就是创建若干个可执行的线程放入一个池(容器)中,有任务需要处理时,会提交到线程池中的任务队列,处理完之后线程并不会被销毁,而是仍然在线程池中等待下一个任务。
6.2 使用线程池有什么好处
降低资源消耗:通过重复利用现有的线程来执行任务,避免多次创建和销毁线程
提高响应速度:因为省去了创建线程这个步骤,所以在拿到任务时,可以立刻开始执行
提供附加功能:线程池的可拓展性使得我们可以自己加入新的功能,比如说定时、延时来执行某些线程
提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配、调优和监控
6.3 实现
Java 给我们提供了 Executor 接口来使用线程池。
我们常用的线程池有这两大类:
- ThreadPoolExecutor
- ScheduledThreadPoolExecutor
线程池创建
Executors 也是 java.util.concurrent 包下的成员,它是一个创建线程池的工厂,可以使用静态工厂方法来创建线程池,下面就是 Executors 所能生成一些常用的线程池:
newSingleThreadExecutor
创建一个单线程的线程池,这个线程池只有一个线程在工作,此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
newFixedTheadPool
创建固定大小的线程池。每次提交一个任务就创建一个线程,知道线程达到线程池的最大大小。线程池的大小一旦达到最大值就保持不变,如果某一个线程因为执行异常结束,那么线程池会补充一个新线程。
newCachedThreadPool
创建一个可缓存的线程池,如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(1分钟不执行)的线程,当任务书增加是,此线程池又可以智能的添加新线程来处理任务,此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统能够创建线程的大小。
newScheduledThreadPool
创建一个大小无限的线程池,此线程池支持定时以及周期性的执行任务的需求。
上面这些线程池的底层实现都是由 ThreadPoolExecutor 来提供支持,所以要理解这些线程池的工作原理,就需要先把 ThreadPoolExecutor 搞明白。
ThreadPoolExecutor线程池类参数详解
corePoolSize:表示核心线程池的大小。当提交一个任务时,如果当前核心线程池的线程个数没有达到 corePoolSize,则会创建新的线程来执行所提交的任务,即使当前核心线程池有空闲的线程。如果当前核心线程池的线程个数已经达到了 corePoolSize,则不再重新创建线程。如果调用了prestartCoreThread()或者 prestartAllCoreThreads(),线程池创建的时候所有的核心线程都会被创建并且启动。
maximumPoolSize:表示线程池能创建线程的最大个数。如果当阻塞队列已满时,并且当前线程池线程个数没有超过 maximumPoolSize 的话,就会创建新的线程来执行任务
keepAliveTime:空闲线程存活时间。如果当前线程池的线程个数已经超过了 corePoolSize,并且线程空闲时间超过了 keepAliveTime 的话,就会将这些空闲线程销毁,这样可以尽可能降低系统资源消耗
unit:时间单位。为 keepAliveTime 指定时间单位
workQueue:阻塞队列。用于保存任务的阻塞队列,关于阻塞队列可以看这篇文章。可以使用ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue, PriorityBlockingQueue
threadFactory:创建线程的工程类。可以通过指定线程工厂为每个创建出来的线程设置更有意义的名字,如果出现并发问题,也方便查找问题原因
handler:饱和策略。当线程池的阻塞队列已满和指定的线程都已经开启,说明当前线程池已经处于饱和状态了,那么就需要采用一种策略来处理这种情况。采用的策略有这几种:
AbortPolicy: 直接拒绝所提交的任务,并抛出RejectedExecutionException异常
CallerRunsPolicy:只用调用者所在的线程来执行任务
DiscardPolicy:不处理直接丢弃掉任务
DiscardOldestPolicy:丢弃掉阻塞队列中存放时间最久的任务,执行当前任务
深入理解线程池
线程池和线程一样拥有自己的状态,在 ThreadPoolExecutor 类中定义了一个 volatile 变量 runState 来表示线程池的状态,线程池有五种状态,分别为RUNNING、SHURDOWN、STOP、TIDYING 、TERMINATED,先附上源码:
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // =29
private static final int CAPACITY = (1 << COUNT_BITS) - 1; // =000 11111...
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS; // 111 00000...
private static final int SHUTDOWN = 0 << COUNT_BITS; // 000 00000...
private static final int STOP = 1 << COUNT_BITS; // 001 00000...
private static final int TIDYING = 2 << COUNT_BITS; // 010 00000...
private static final int TERMINATED = 3 << COUNT_BITS; // 011 00000...
// 线程池的状态
private static int runStateOf(int c) { return c & ~CAPACITY; }
// 线程池中工作线程的数量
private static int workerCountOf(int c) { return c & CAPACITY; }
// 计算ctl的值,等于运行状态“加上”线程数量
private static int ctlOf(int rs, int wc) { return rs | wc; }
RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务
SHUTDOWN:状态关闭,不在接受新提交的任务,但是能继续处理阻塞队列已保存的让任务
STOP:不接受新任务,也不处理队列中的任务,会中断正在处理任务的线程
TIDYING:所有让任务都已终止,workerCount(有效处理让任务线程)状态为0
TERMINATED:在 terminated() 方法执行结束后进入该状态,此时表示线程池的彻底终止
这几个状态的转化关系为:
调用 shundown() 方法线程池的状态由 RUNNING——>SHUTDOWN
调用 shutdowNow() 方法线程池的状态由 RUNNING——>STOP
当任务队列和线程池均为空的时候 线程池的状态由 STOP/SHUTDOWN——–>TIDYING
当 terminated() 方法被调用完成之后,线程池的状态由 TIDYING———->TERMINATED状态
提交任务
通过 ThreadPoolExecutor 创建线程池处于运行状态,此时线程数量为 0,提交任务后执行过程是怎样的,下面我们看 ThreadPoolExecutor 的执行示意图和执行流程图:
execute 方法执行逻辑有这样几种情况:
如果当前运行的线程少于 corePoolSize,则会创建新的线程来执行新的任务
如果运行的线程个数等于或者大于 corePoolSize,则会将提交的任务存放到阻塞队列 workQueue 中
如果当前 workQueue 队列已满的话,则会创建新的线程来执行任务
如果创建新线程会使当前运行的线程超过 maximumPoolSize 的话,任务将被拒绝,并且使用 RejectedExecutionHandler.rejectEExecution() 方法拒绝新的任务
7. threadLocal的原理
7.1 概念
ThreadLocal 是 Java 提供的一种线程局部变量,它为每个使用该变量的线程都提供了一个独立的副本,从而每个线程都可以独立地改变自己的副本,而不会影响其他线程的副本。简而言之,ThreadLocal 实现了线程级别的数据隔离,非常适用于在多线程环境下各线程需要拥有独立的变量副本的场景。
7.2 创建和使用 ThreadLocal
public class ThreadLocalExample { // 创建一个 ThreadLocal 实例 private static ThreadLocal<Integer> threadLocalValue = ThreadLocal.withInitial(() -> 0); public static void main(String[] args) { // 创建两个线程 Thread thread1 = new Thread(new Task(), "Thread-1"); Thread thread2 = new Thread(new Task(), "Thread-2"); thread1.start(); thread2.start(); } static class Task implements Runnable { @Override public void run() { // 获取当前线程的 ThreadLocal 变量值 int value = threadLocalValue.get(); System.out.println(Thread.currentThread().getName() + " initial value: " + value); // 修改 ThreadLocal 变量值 threadLocalValue.set(value + 1); System.out.println(Thread.currentThread().getName() + " modified value: " + threadLocalValue.get()); } } }
在以上代码中,我们创建了一个 ThreadLocal
实例,并初始化为 0
。每个线程在运行时,都会获取和修改自己线程独有的 ThreadLocal
变量值,互不干扰。
重要方法介绍
ThreadLocal.withInitial(Supplier<? extends T> supplier):创建一个带有初始值的 ThreadLocal 实例。
get():获取当前线程的线程局部变量值。
set(T value):设置当前线程的线程局部变量值。
remove():移除当前线程的线程局部变量值,防止内存泄漏。
7.3 深入理解 ThreadLocal 的工作原理
ThreadLocalMap
是 ThreadLocal
类的一个内部类,简化的实现如下:
每个 ThreadLocalMap 是一个数组,数组的每个元素都是一个 Entry,它继承自 WeakReference,键是 ThreadLocal 的弱引用,值是实际存储的线程局部变量值。这种设计可以在 ThreadLocal 对象被回收时,允许垃圾收集器回收对应的 Entry,避免内存泄漏。
7.4 使用场景
1. 线程安全的对象使用
在多线程环境下,我们可能需要每个线程使用自己独立的对象实例,例如 SimpleDateFormat
。SimpleDateFormat
不是线程安全的,使用 ThreadLocal
可以为每个线程提供一个独立的 SimpleDateFormat
实例:
2. 数据库连接管理
在基于线程的环境中(如 Web 应用),可以使用 ThreadLocal
来管理数据库连接,为每个线程提供独立的连接实例,以避免连接共享带来的线程安全问题。
3. 用户会话管理
在 Web 应用中,可以使用 ThreadLocal
来存储当前线程的用户会话信息,以便在请求处理的各个环节中方便地访问用户数据
注意事项
内存泄漏问题:由于 ThreadLocalMap 使用的是 ThreadLocal 的弱引用,但值是强引用,如果线程池长时间不释放线程,可能会导致内存泄漏。因此,使用 ThreadLocal 时应在不再需要时调用 remove() 方法清理变量。
适用场景:ThreadLocal 适用于每个线程需要独立的实例或数据的场景,不适用于需要线程间共享数据的场景。
性能问题:对于频繁创建和销毁线程的场景,ThreadLocal 的创建和销毁开销可能较大,因此更适合于线程池等长生命周期的线程管理场景。
8.什么是红黑树及其原理
红黑树,是一种二叉搜索树,但在每个结点上增加一个存储位表示结点的颜色,可以是Red或Black。 通过对任何一条从根到叶子的路径上各个结点着色方式的限制,红黑树确保没有一条路径会比其他路径长出俩倍,因而是接近平衡的。
红黑树的性质
1. 每个结点不是红色就是黑色
2. 根节点是黑色的
3. 如果一个节点是红色的,则它的两个孩子结点是黑色的
4. 对于每个结点,从该结点到其所有后代叶结点的简单路径上,均 包含相同数目的黑色结点
5. 每个叶子结点都是黑色的(此处的叶子结点指的是空结点)
为什么满足上面的性质,红黑树就能保证:其最长路径中节点个数不会超过最短路径节点个数的两倍?
1.红黑树中的性质确保了任意路径上的黑色节点数量相等,这是红黑树能够保持平衡的关键。根据这个性质,我们可以证明红黑树的最长路径中的节点个数不会超过最短路径的节点个数的两倍。
2.假设红黑树的最短路径上的黑色节点数量为k。由于性质5,任意路径上的黑色节点数量相等,所以最长路径上的黑色节点数量不能少于k个。
3.现在我们来看最长路径上的节点个数。由于性质4,红色节点的两个子节点都是黑色的,因此最长路径上不能有连续的红色节点。而对于红黑树而言,最长路径上的红色节点数量最多为k,因为最长路径上的黑色节点数量不能少于k个。所以最长路径上的节点个数最多为2k,其中k个是黑色节点,k个是红色节点。
4.综上所述,红黑树的最长路径中的节点个数不会超过最短路径的节点个数的两倍,即最长路径上的节点个数最多为2k,其中k为最短路径上的黑色节点数量。
5.这个性质保证了红黑树的高度始终保持在较小的范围内,从而保持了树的平衡性。因为红黑树的高度与最长路径上的节点个数成正比,所以最长路径的节点个数的上限为最短路径的节点个数的两倍,确保了红黑树的平衡性和高效性。