1、进程和线程的区别
1、进程是资源分配的基本单位,线程是程序执行的最小单位
2、一个进程包括多个线程
3、每个进程都有自己的内存和资源,一个进程中的线程会共享这些内存和资源,每个线程都有单独的栈内存,和寄存器
2、并行和并发的区别
- 并行指两个或多个事件在同一时刻发生,是在不同实体上的多个事件
- 并发指两个或多个事件在同一时间间隔内发生,是在同一实体上的多个事件
3、创建线程的方式
1、继承Thread类,重写run()方法,无返回值,不抛出异常
2、实现Runnable接口,重写run()方法,无返回值,不抛出异常
3、实现Callable接口,重写call()方法,有返回值,可以抛出异常,再当做参数传给FutureTask对象,FutureTask对象再作为参数再传递给Thread对象来启动线程
4、通过线程池来创建线程
4、sleep()和wait()的区别
1、sleep()不会释放锁,wait()会释放锁。(sleep()睡眠完之后重新回到运行状态,wait()等待完成后,回到就绪状态)
2、sleep()是Thread的方法,wait()是Object的方法
3、sleep()可以在方法的任何地方使用,wait()只能在同步代码块中使用
5、synchronized和Lock的区别
1、synchronized会自动释放锁,Lock不会自动,需手动加锁、解锁
2、synchronized锁之后,等待线程不能获取锁的状态,会一直等下去,而Lock锁之后,等待线程可以获取锁的状态,tryLock()尝试获取到锁。
3、synchronized可以锁方法和代码块,一般用于锁少量代码,Lock锁只能锁代码块,一般用于锁大量代码块
4、synchronized是java关键字,lock是java接口
6、什么是死锁?死锁产生的条件?如何避免死锁?
- 死锁就是两个或多个线程各自占有对方想要的资源,而对自己手中的资源不放,而造成循环等待,形成死锁。
死锁产生的四个条件:
如何避免死锁,就是破坏这四个必要条件:
破坏不可剥夺条件,一个线程在等待时,将它占有的资源重新加入到资源分配列表中,等到该线程重新获得要等待资源及原有资源才执行下去
破坏请求和保持条件:
1、静态分配即每个线程在开始执行时就申请他所需要的全部资源
2、动态分配即每个线程在申请所需要的资源时,他本身不占用系统资源
破坏循环等待条件:
采用资源有序分配,其基本思想就是将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。
7、线程池有哪些?
1、newCacheThreadPool()创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
2、newFixedThredPool()创建一个定长线程池,可控制线程的最大并发数,超出的线程会在队列中等待。
3、newScheduleThreadPool()创建一个定长线程池,支持定时及周期性任务执行。
4、newSingleThreadExecutor()创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,优先级)执行。
5、newSingleThreadScheduledExecutor()创建一个单线程化的线程池,支持定时及周期性任务执行。
6、new ThreadPoolExecutor()方法创建线程池时,要设置的7大参数:
(阻塞队列这里有点错误,正确的是当线程数超过核心线程池大小,然后再阻塞队列中存储线程。)
创建线程流程:
如果此时线程池中的线程数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
如果此时线程池中的线程数量等于 corePoolSize,但是缓冲队列 workQueue未满,那么任务被放入缓冲队列。
如果此时线程池中的线程数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的线程数量小于maximumPoolSize,建新的线程来处理被添加的任务。
如果此时线程池中的线程数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的线程数量等于maximumPoolSize,那么通过 handler所指定的策略来处理此任务。
也就是:处理任务的优先级为:
核心线程corePoolSize、任务队列workQueue、最大线程maximumPoolSize,如果三者都满了,使用handler处理被拒绝的任务。
当线程数超过最大线程池的大小和阻塞队列中的线程数时,就会有拒绝策略拒绝多余的线程。其中有4种拒绝策略。
8、线程池的状态?
Running
线程池处于Running状态时,能够接收新任务以及对已添加的任务进行处理。
线程池的初始状态为Running,换句话说线程池一旦被创建,就处于Running状态,且线程池中的任务数为0
Shutdown
线程池处于SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。
调用线程池的shutdown()方法时,线程池由Running->Shutdown
Stop
线程池处于Stop状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务的线程。
调用线程池的shutdownNow()方法时,线程池由Running->stop
Tidying
当所有的任务已终止,ctl记录的任务数为0,线程池的状态就会变为tidying。
当线程池中的任务为空,且阻塞队列中的任务数也为空时,状态就会由shutdown->tidying
当线程池中的任务为空时,状态由 stop -> tidying
Terminated
线程池处于tidying状态时,调用terminated()就会由tidying-> terminated
9、线程池为何要构建空任务的非核心线程?
防止阻塞队列中还有任务存在,但此时可能核心线程数为零,要执行的话只能等待下一次添加新任务到队列中。所以在线程快结束的时候,创建空任务的非核心线程就是为了防止这种情况发生。
10、线程池使用完毕为何必须要shutdown方法?
如果没有及时shutdown,那么线程池中的核心线程永远不会被gc回收,那就有可能造成内存泄露问题。
11、CAS是什么?
CAS(compare and swap),比较并交换。可以解决多线程并行情况下使用锁造成的性能损耗的一种机制。CAS操作包含三个操作数–内存位置(V),预期原值(A),新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做处理。
CAS产生:
在修饰共享变量的时候经常使用volatile关键字,但是volatile值有可见性和禁止指令重拍(有序性),无法保证原子性。虽然在单线程中没有问题,但是多线程就会出现各种问题,造成现场不安全的现象。所以jdk1.5后产生了CAS利用CPU原语(不可分割,连续不中断)保证现场操作原子性。
CAS应用:
在JDK1.5 中新增java.util.concurrent(JUC)就是建立在CAS之上的。相对于对于synchronized这种锁机制,CAS是非阻塞算法的一种常见实现。所以JUC在性能上有了很大的提升。
比如AtomicInteger类,AtomicInteger是线程安全的的,下面是源码:
进入unsafe看到do while自循环,这里的自循环,就是在 判断预期原值 如果与原来的值不符合,会再循环取原值,再走CAS流程,直到能够把新值赋值成功。
CAS优点:
cas是一种乐观锁的思想,而且是一种非阻塞的轻量级的乐观锁,非阻塞式是指一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。
CAS 缺点:
1、循环时间长开销大,占用CPU资源。如果自旋锁长时间不成功,会给CPU带来很大的开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
2、只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
3、ABA问题
解决ABA问题:
- 添加版本号
- AtomicStampedReference
12、对volatile的理解,volatile的可见性和禁止指令重排是如何实现的?
在代码中加入volatile关键字时,生成的汇编代码会出现一个Lock前缀指令,实际上就是一个内存屏障。它有3个功能:
1、volatile的可见性:
volatile的功能就是,将被修改的变量,在被修改后可以立即同步到主内存,被修改的变量在每次被使用之前都从主内存刷新,其实本质是通过MESI缓存一致性协议来实现可见性。
可见性原理:
1、首先cpu会根据共享变量是否带有Volatile字段,来决定是否使用MESI协议保证缓存一致性。
2、如果有Volatile,汇编层面会对变量加上Lock前缀,当一个线程修改变量的值后,会马上经过store、write等原子操作修改主内存的值(如果不加Lock前缀不会马上同步),为什么监听到修改会马上同步呢?就是为了触发cpu的嗅探机制,及时失效其他线程变量副本。
具体看这里 volatile详情
2、volatile禁止指定重排:
volatile关键字的另外一个作用就是禁止指定重排优化,从而避免多线程下程序出现乱序执行的现象。以下单例模式的双重检索就是一个好的实例:
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
首先,对象初始化分3个步骤:
1、给对象分配内存空间
2、初始化对象
3、对象的引用指向分配好的内存地址
如果没有加上volatile时,就可能在初始化对象的时候发生重排序,第二步和第三步互换了,先引用指向内存地址了,此时另外一个线程在外面判断对象是否为空的时候,这是对象不为空就直接返回了,这时候返回的对象没有进行初始化,会导致错误,所以加上volatile关键字禁止指令重排,确保给对象初始化完成后,再执行内存地址。
这时候volatile也是通过内存屏障来禁止指令重排,完成有序性的。具体的内存屏障策略为:storestore,storeload,loadload,loadstore
屏障类型 |
指令示例 |
说明 |
LoadLoad |
Load1; LoadLoad; Load2 |
保证load1的读取操作在load2及后续读取操作之前执行 |
StoreStore |
Store1; StoreStore; Store2 |
在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存 |
LoadStore |
Load1; LoadStore; Store2 |
在stroe2及其后的写操作执行前,保证load1的读操作已读取结束 |
StoreLoad |
Store1; StoreLoad; Load2 |
保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行 |
13、as-if-serial和happens-before规则
1、as-if-serial定义:无论编译器和处理器如何进行重排序,单线程程序的执行结果不会改变。
2、happens-before关系保证正确同步的多线程的执行结果不被改变
happens-before具体定义:
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的顺序排在第二个之前。
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
happens-before一共有七项规则:
- 程序顺序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
- 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁加锁。
- volatile变量规则:对于一个volatile域的写,happen-before于任意后续对这个volatile的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
- 程序中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数结束)先行发生于它的finalize()方法的开始。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能的提高程序执行的并行度。
14、对象的创建过程?
创建对象的一共有六个步骤:
1、检查类是否已经被加载
2、为对象分配内存空间
3、将分配到内存空间初始化默认值(int初始化为0,boolean初始化为false,string初始化为null)
4、为对象进行必要的设置(设置对象头,包括该对象所属的类,类的元数据信息,hasCode值,gc年龄)
5、初始化类信息
- 执行Class文件中的()方法
- 初始化对象的字段值
- 静态代码块
- 构造代码块
- 构造方法
6、对象引用执行分配好内存地址
15、对象在内存中的内存布局
对象在堆内存中的布局划分为三个部分:
1、对象头(Mark Word{hasCode值,gc年龄,偏向锁线程id,偏向时间,锁的状态等等}、Class pointer(是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;))
2、实例数据
3、对齐填充
数组对象的内存分布:
1、对象头
2、实例数据
3、对齐填充
4、数组长度(4个字节)
一个比较坑的题目 Object objet = new Object()占用多少字节?
总结:所以Object obj = new Object()一个 如果默认情况下是占用20个字节(merkword8字节+classponit4字节+对齐4字节+obj4字节),如果关闭压缩指令,那么就是占用也是20个字节(merkword8字节+classponit8字节+obj4字节),因为用不到补齐了,哈哈是不是被坑了。
具体看对象内存分布
内存分布为什么要加上对齐填充,内存的字节数为什么要是8的倍数呢?
答:64位 机器下,单次可读写 64 bit(即 8 byte) 数据,因此每 8 byte 作为一 读取单元。计算机并非逐字节读取内存,而是按8的倍数的字节块读写内存,故地址必须为上述倍数,故各种数据类型需要按照一定规则在空间上排列,以提高访问效率。
16、锁的四种状态及升级过程
一、前言
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁。并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
在synchronized最初的实现方式是“阻塞或唤醒一个Java线程需要操作系统切换CPU来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切切换的时间可能比用户执行的时间还长“,这种方式效率低下,由此,JDK1.6带来了偏向锁、轻量级锁。
四种锁的升级过程如下:
锁升级过程总结:
1、在一个线程没有执行到同步代码块的时候,这时锁的状态是无锁的,mark word里面存储的是对象的hashCode值,对象的分代年龄,是否偏向锁0,锁标志位01这些信息。
2、当线程执行到同步代码块边界时,且没有线程执行该代码块时,锁对象会变成偏向锁,通过CAS操作把把线程id存储到mark word里的偏向锁的id,Epoch实际上存储的是偏向时间戳,以及对象分代年龄,是否偏向锁1,锁标志位01这些信息。
3、如果此时有另外一个线程执行到同步代码块时,此时mark word里的偏向锁线程id去除掉,且锁升级为轻量级锁(自旋锁),线程会通过CAS操作不断的自旋,尝试获取到锁。而获取到锁的线程,会将线程的指向栈中锁的记录的指针存入到mark word里,且锁标志位为00。
4、而其他没有获取到锁的线程还是会通过CAS不断的尝试获取到锁,大量的消耗CPU资源。JVM默认线程自旋失败十次的话,锁再次升级为重量级锁,(不然会导致太多线程不断的自旋,导致CPU挂满),此时将获得重量级锁的指针存入到mark word中,且锁标志位为10,没有获取到锁的线程则会阻塞挂起,等待cpu调度。这些就是锁升级的大致过程。
锁对比:
锁 |
优点 |
缺点 |
适用场景 |
偏向锁 |
加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 |
如果线程间存在锁竞争,会带来额外的锁撤销的消耗 |
适用于只有一个线程访问同步块场景 |
轻量级锁 |
竞争的线程不会阻塞,提高了程序的响应速度 |
如果始终得不到索竞争的线程,使用自旋会消耗CPU |
追求响应速度,同步块执行速度非常快 |
重量级锁 |
线程竞争不使用自旋,不会消耗CPU |
线程阻塞,响应时间缓慢,用户态和内核态来回切换影响性能。 |
追求吞吐量,同步块执行速度较慢 |
锁:
其实锁的信息都是存放在对象头中的,以Hotspot为例,对象头主要包括Mark word(标记字段) Klass pointer(类型指针)。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
以下就是具体的存储信息
17、请你介绍下synchronized及其底层原理
Synchronized是一个隐式的、可重入的、不可中断的同步锁,可以同步方法(默认锁对象为this(当前实例对象)),同步代码块(自己指定锁对象)。
底层实现:
1、对象头是 synchronized 实现的关键,使用的锁对象是存储在 Java 对象头里的、及锁升级过程信息也存储在对象头中的对象头的。具体看12问、13问。
2、从java代码反编译来看,如下图所示,每当线程执行到同步代码块中就会有个monitorenter和monitorexit命令的出现,其实每个对象都存在着一个monitor与之关联,当线程执行到代码块时,会尝试使用monitorenter命令获取到该对象的monitor(监视器锁),获取成功后,该monitor便处于锁定状态。
monitor(监视器锁)本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。Mutex Lock的切换需要CPU从用户态转换为内核态中,因此状态转换需要耗费很多的处理时间,所以Synchronized是一个重量级操作。
synchronized可重入原理:
上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是:不需要获取该锁,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。
Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
18、请描述下AQS,为什么AQS的底层是CAS+volatile?
AQS是AbstractQueuedSynchronizer的简称,AQS提供了一种实现阻塞锁和一系列依赖FIFO等待队列的同步器的框架,AQS底层实现原理用一句话总结就是:volatile + CAS + 一个虚拟的FIFO双向队列(CLH队列)。
AQS为一系列同步器依赖于一个单独的原子变量(state)的同步器提供了一个非常有用的基础。子类们必须定义改变state变量的protected方法,这些方法定义了state是如何被获取或释放的。鉴于此,本类中的其他方法执行所有的排队和阻塞机制。子类也可以维护其他的state变量,但是为了保证同步,必须原子地操作这些变量。
使用一个volatile的int类型的state表示同步状态,通过内置的FIFO队列CLH完成资源获取的排队工作,将资源封装为Node,通过cas改变state值
AQS同时提供了互斥模式(exclusive)和共享模式(shared)两种不同的同步逻辑。一般情况下,子类只需要根据需求实现其中一种模式,当然也有同时实现两种模式的同步类,如ReadWriteLock。
自定义资源共享方式:
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
示例:如使用Reentranlock使用非公平锁时,上锁的操作,如果使用cas操作失败的话,会进入acquire方法中,将该线程通过addWaiter方法独占方式加入等待队列。
为什么AQS的底层是CAS+volatile?
答:如上述代码所示往队列里面加thread的时候,是通过CAS的方法 compareAndSetTail() 往尾巴tail上加。而tail是volatile的。还有比如修改state的值,也是通过CAS的方法 compareAndSetState() 来修改,而state也是volatile的。
19、AQS唤醒节点时,为何从后往前找。
两个方面点:
(1)node节点在插入AQS双向链表时,是从tail中去添加的,首先是吧prve指向最后一个节点,然后将tail指向新添加的节点,他就算完成了插入。但其实还原本最后一个节点的next还没有指向新添加的节点,所以如果从前往后唤醒的话,就会导致新添加的node节点会丢失。而从后往前则不会。
(2)还是一个就是当链表中的某个节点取消,它会先去调整prev这个指针的指向,再去调整next的指向,所以prev指向优先级更好,所以从前往后可能会造成错过某个节点。
20、synchronized和reentrantlock的底层实现及重入的底层原理
synchronized的底层实现
(1)synchronized是用来保证线程同步,用的锁存在java对象头中,利用monitorenter和monitorexit指令实现,monitorenter指令是在编译后插入到同步代码块开始位置,而monitorexit是插入到方法结束后和异常处。jdk1.6之后引入了大量的优化,这其中又涉及到锁的四种升级状态:new(无锁) →偏向锁→轻量级锁(自旋锁)→重量级锁。而底层,synchrnoized是利用操作系统的Mutex Lock(互斥锁)来实现的,
(2)synchronized同步块对同一条线程来说是可以重入的,不会出现自己把自己锁死的问题。同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。由于java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间,所以synchronized是java语言中一个重量级操作。
(3)重入锁实现可重入性原理或机制:每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记录下锁的持有线程,并且将计数器置为1;此时其他线程请求该锁,则必须等待;而持有该锁的线程如果再次请求这个锁,就可以再次拿到这把锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器变为0,则释放锁。
reentantlock的底层实现原理
(4)ReentrantLock是基于AQS的,AQS是Java并发包中众多同步组件的构建基础,它通过一个int类型的状态变量state和一个FIFO队列来完成共享资源的获取,线程的排队等待等。AQS是个底层框架,采用模板方法模式,它定义了通用的较为复杂的逻辑骨架,比如线程的排队,阻塞,唤醒等,将这些复杂但实质通用的部分抽取出来,这些都是需要构建同步组件的使用者无需关心的,使用者仅需重写一些简单的指定的方法即可(其实就是对于共享变量state的一些简单的获取释放的操作)。ReentrantLock的处理逻辑,其内部定义了三个重要的静态内部类,Sync,NonFairSync,FairSync。Sync作为ReentrantLock中公用的同步组件,继承了AQS(要利用AQS复杂的顶层逻辑嘛,线程排队,阻塞,唤醒等等);NonFairSync和FairSync则都继承Sync,调用Sync的公用逻辑,然后再在各自内部完成自己特定的逻辑(公平或非公平)。
(5)reentantlock的非公平锁的实现:对于共享变量state,先通过cas尝试是否能获取到锁,先获取state值,若为0,意味着此时没有线程获取到资源,CAS将其设置为1,设置成功则代表获取到排他锁了;若state大于0,肯定有其他线程已经抢占资源,此时再去判断是否就是自己抢占的,是的话,state累加,返回true,重入成功,state的值即是线程重入的次数,其他情况,则获取失败;
(6)reentantlock的可重入公平锁的实现:公平锁的大致逻辑与非公平锁是一致的,不同的地方在于有了!hasQueuedPredecessors()这个判断逻辑,即便state为0,也不能贸然直接去获取,要先去看有没有还有比该线程排队的还久的线程,若没有,才能尝试去获取,做后面的处理。反之,返回false,获取失败。
21、强、软、弱、虚引用
其实强引用、软引用、弱引用、虚引用这四个概念非常简单好记。
- 强引用:gc时都不会回收
- 软引用:只有在内存不够用时,gc才会回收
- 弱引用:只要gc就会回收
- 虚引用:是否回收都找不到引用的对象,获取永远都为null,仅用于管理堆外内存。因为JVM无法直接清理堆外内存,所以提供一个虚引用,交给垃圾回收器的回收队列,这个队列就是用来标记哪些堆外内存需要回收,再调用c++释放空间。
22、你清楚ThreadLocal吗,ThreadLocal如何解决内存泄漏问题?
ThreadLocal意为线程本地变量,用于解决多线程并发时访问共享变量的问题。
很明显,在多线程的场景下,当有多个线程对共享变量进行修改的时候,就会出现线程安全问题,即数据不一致问题。常用的解决方法是对访问共享变量的代码加锁(synchronized或者Lock)。但是这种方式对性能的耗费比较大。在JDK1.2中引入了ThreadLocal类,来修饰共享变量,使每个线程都单独拥有一份共享变量,这样就可以做到线程之间对于共享变量的隔离问题。
一、ThreadLocal设计
1、在JDK早期的设计中,每个ThreadLocal都有一个map对象,将线程作为map对象的key,要存储的变量作为map的value,但是现在已经不是这样了。
2、JDK8之后,每个Thread维护一个ThreadLocalMap对象,这个Map的key是ThreadLocal实例本身,value是存储的值要隔离的变量,是泛型,其具体过程如下:
-
每个Thread线程内部都有一个Map(ThreadLocalMap::threadlocals);
-
Map里面存储ThreadLocal对象(key)和线程的变量副本(value);
-
Thread内部的Map由ThreadLocal维护,由ThreadLocal负责向map获取和设置变量值;
-
对于不同的线程,每次获取副本值时,别的线程不能获取当前线程的副本值,就形成了数据之间的隔离。
JDK8之后设计的好处在于:
每个Map存储的Entry的数量变少,在实际开发过程中,ThreadLocal的数量往往要少于Thread的数量,Entry的数量减少就可以减少哈希冲突。
当Thread销毁的时候,ThreadLocalMap也会随之销毁,减少内存使用,早期的ThreadLocal并不会自动销毁。
二、ThreadLocal内存泄露问题
内存泄露问题:指程序中动态分配的堆内存由于某种原因没有被释放或者无法释放,造成系统内存的浪费,导致程序运行速度减慢或者系统奔溃等严重后果。内存泄露堆积将会导致内存溢出。
使用ThreadLocal造成内存泄露的问题是因为:ThreadLocalMap的生命周期与Thread一致,如果不手动清除掉Entry对象的话就可能会造成内存泄露问题。
因此,需要我们在每次在使用完之后需要手动的remove掉Entry对象。
解决办法:
(1)尽量使用private static final修饰ThreadLocal实例。使用private与final修饰符主要是为了尽可能不让他人修改、变更ThreadLocal变量的引用,使用static修饰符主要是为了确保ThreadLocal实例的全局唯一。
(2)ThreadLocal使用完成之后务必调用remove()方法。这是简单、有效地避免ThreadLocal引发内存泄漏问题的方法。