博主现在大三在读,从三月开始找暑期实习,暑假准备去tx实习啦!总结下了很多面试真题,希望能帮助正在找工作的大家!相关参考都会标注原文链接,尊重原创!
参考:
synchronized有两种形式上锁:同步方法、同步代码块。它们底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。
1️⃣ 同步代码块
//synchronized修饰代码块
public class Test implements Runnable {
@Override
public void run() {
// 加锁操作
synchronized (this) {
System.out.println("hello");
}
}
public static void main(String[] args) {
Test test = new Test();
Thread thread = new Thread(test);
thread.start();
}
}
javap -c
查看相应的class文件:
可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。
为什么会有两个monitorexit呢?
这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。
2️⃣ 同步方法
public class Test implements Runnable {
@Override
public synchronized void run() {
System.out.println("hello again");
}
public static void main(String[] args) {
Test test = new Test();
Thread thread = new Thread(test);
thread.start();
}
}
再次用javap -c
查看相应的class文件:
仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit
Monitor
可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁,称为内部锁或者 Monitor 锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
Synchronized
是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking=false来禁用偏向锁。
深入理解synchronized底层原理,一篇文章就够了!
synchronized的底层实现原理及各种优化
关于 锁的四种状态与锁升级过程 图文详解
synchronized
用的锁是存在Java对象头里的,那么什么是对象头呢?
在JVM中,对象是分成三部分存在的:对象头、实例数据、对其填充。实例数据存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐;对其填充不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。
以上两者与synchronized无关,==对象头==是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。
我们以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段)和Klass Pointer(类型指针)
在上面中我们知道了,synchronized
用的锁是存在Java对象头里的,那么具体是存在对象头哪里呢?答案是:存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?
在64位的虚拟机中:
在32位的虚拟机中:
下面我们以 32位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的
无锁:对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01
偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01
轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00
重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11
GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。
锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁,这四种锁状态分别代表什么,为什么会有锁升级?
其实在 JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁,最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,这种方式就是 synchronized
实现同步最初的方式,这也是当初开发者诟病的地方,这也是在JDK6以前 synchronized
效率低下的原因
在JDK 1.6后,Jvm为了提高锁的获取与释放效率对synchronized进行了优化,引入了 偏向锁 和 轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别),意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
锁的类型和状态在对象头Mark Word
中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word
数据。
锁状态 | 存储内容 | 标志位 |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量的指针 | 11 |
锁对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到索竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
偏向锁针对的是锁不存在竞争,每次仅有一个线程来获取该锁,为了提高获取锁的效率,因此将该锁偏向该线程。提升性能。
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。
当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
偏向锁考虑的是不存在多个线程竞争同一把锁,而轻量级锁考虑的是,多个线程不会在同一时刻来竞争同一把锁。
轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。
轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
重量级锁描述同一时刻有多个线程竞争同一把锁。
重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源
从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,除了新增了两个锁状态,还通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升
自旋锁:
线程未获得锁后,不是一昧的阻塞,而是让线程不断尝试获取锁。
缺点:若线程占用锁时间过长,导致CPU资源白白浪费。
解决方式:当尝试次数达到每个值得时候,线程挂起。
自适应自旋锁:
自旋得次数由上一次获取锁的自旋次数决定,次数稍微延长一点点。
锁消除
对于线程的私有变量,不存在并发问题,没有必要加锁,即使加锁编译后,也会去掉。
锁粗化
当一个循环中存在加锁操作时,可以将加锁操作提到循环外面执行,一次加锁代替多次加锁,提升性能
ThreadLocal 是线程本地存储,在每个线程中都创建了一个ThreadLocalMap 对象,它存储本线程中所有ThreadLocal对象及其对应的值,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。
由于每一条线程都含有线程私有的ThreadLocalMap容器,这些容器间相互独立不影响,因此不会存在线程安全的问题,从而无需使用同步机制来保证多条线程访问容器的互斥性
ThreadLocalMap
是ThreadLocal
的内部类,可以理解为一个map容器,由一个个key-value对象Entry
构成
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xWiFADeq-1621873942557)(C:/Users/zsr204/AppData/Roaming/Typora/typora-user-images/image-20210430082031627.png)]
看到Entry
继承自WeakReferencr
,就是一个key-value形式的对象。它的key就是ThreadLocal对象,并且是一个弱引用,如果没有指向key的强引用后,该key就会被垃圾回收器回收;Entry的value就是存储相应ThreadLocal对象的值
当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象,再以当前的ThreadLocal对象为key,将值存入ThreadLocalMap对象中
get方法执行过程类似,首先ThreadLocal获取当前线程对象,然后获取当前线程的ThreadLocalMap对象,再以当前的ThreadLocal对象为key,获取对应的value
**内存泄露 **为程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存迟早会被占光
简单来说,不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄露。与OOM的区别:内存泄漏是内存占用无法释放,而OOM是内存不够,内存泄漏会导致OOM
强引用:通过new或反射构造出来的对象都具有强引用,不会被垃圾回收器回收。当内存空间不足时,JVM宁愿OOM报错,使程序异常终止,也不会回收这种对象
如果想要取消强引用和某个对象之间的关联,可以显示将引用赋值为null,这样JVM就可以在合适的时间对其回收
弱引用:在java中用java.lang.ref.WeakReference
类来表示的对象具有弱引用。JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。可以在缓存中使用弱引用。
因此,ThreadLocal内存泄漏的根本原因是:由于ThreadLocalMap的生命周期和Thread一样长,线程不结束,其中的value值就回收不掉,如果没有手动删除就会导致内存泄漏
ThreadLocal的正确使用方法:
为什么要使用线程池?
七大参数
corePoolSize
:核心线程数,就是正常情况下创建的工作线程数,这些线程创建后并不会消除,而是一种常驻线程maxnumPoolSize
:最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求的时候,此时会创建新的线程,但是线程池内的总线程数不会超过最大线程数keepAliveTime
:表示超过核心线程数之外的线程的空闲存活时间,也就是核心线程不会被消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除,可以通过setKeepAliveTime来设置空闲时间,单位为unitunit
:超时单位workQueue
:任务队列,用来存放待执行的任务,假设此时的核心线程都已经被使用,还有任务进来则直接放入任务队列,直到整个队列被放满任务还在持续进入则会开始创建新的线程,如果线程达到最大线程数且任务队列也满了,就会执行拒绝策略Handler
:任务拒绝策略,有两种情况:第一种是当我们调用shutdown等方法关闭线程池后,此时即使线程内部还有没执行完的任务在执行,但是由于线程池已经关闭,我们再继续让线程池提交任务就会被拒绝。第二种情况是达到最大线程数,线程池已经没有能力继续处理新提交的任务,会拒绝。ThreadFactory
:线程工厂,用来生产线程执行任务。我们可以选择使用默认的线程工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护进程。我们也可以选择自定义线程工厂,根据业务的不同指定不同的线程池中阻塞队列的作用
普通队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前要入队的任务;而阻塞队列可以通过阻塞保留住当前想要继续入队的任务。
此外,当任务队列中没有任务时,阻塞队列可以阻塞要获取任务的线程,让其进入wait状态,释放cpu资源。
阻塞队列自带阻塞和唤醒的功能,不需要额外的处理,无任务执行时,线程池利用阻塞队列的take方法将线程挂起,从而维护核心线程的存活,不至于一直占用cpu资源
为什么先添加队列而不是先创建最大线程?
因为创建新线程时,需要获取全局锁,会阻塞其他的线程,十分耗费资源,影响了整体的效率。
就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task>core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会千完的,要是任务还在继续增加,超过正式工的加班忍耐极限了队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)
线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread创建线程时的一个线程必须对应一个任务的限制。
在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个循环任务,在这个循环任务
中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务的run方法,将run方法作为一个普通的方法来执行,通过这种方式只使用固定的线程就将所有任务的run方法串联起来。
也就是说业务逻辑没有写在线程池中线程的run方法里,而是利用这些线程调用任务里的run方法,实现线程复用
线程通常有五种状态:创建、就绪、运行、阻塞、死亡
其中阻塞的状态分为三种:
等待池
中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify/notifyAll方法才能唤醒。wait是Object类中的方法锁池
中首先理解两个概念:
- 锁池:所有需要竟争同步锁的线程都会放在锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,当前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列进行等待cpu资源分配
- 等待池:当我们调用wait()方法后,线程会放到等待池当中,等待池的线程是不会去竞争同步锁。只有调用了notify()或notifyAll后等待池的线程才会开始去竟争锁,notify()是随机从等待池选岀一个线程放到锁池,而notifyAll()是将等待池的所有线程放到锁池当中
sleep
和wait
的区别
sleep是Thread类的静态本地方法,wait则是Object类的本地方法
sleep方法不会释放lock,但是wait会释放,而且会加入到等待池中
sleep就是把cpu的执行资格和执行权释放出去,不再运行此线程,当定时时间结束再取回cpu资源,参与cpu的调度,获取到cpu资源后就可以继续运行了。而如果sleep时该线程有锁,那么sleep不会释放这个锁,而是把锁带着进入了冻结状态,也就是说其他需要这个锁的线程不可能获取到这个锁。如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程立即退出被阻塞状态,并抛出一个InterruptedException异常,这点和wait是一样的,以便发生异常中断也可以使wait等待的线程唤醒
sleep方法不依赖于同步器 synchronized,但是wait需要依赖synchronized关键字
sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不带参数的方法需要,带时间参数的不需要)
sleep一般用于当前线程休眠,或者轮循暂停操作,wait则多用于多线程之间的通信
sleep会让出CPU执行时间且强制上下文切换,而wait则不一定,wait后可能还是有机会重新竞争到锁继续执行的
yeild
和join
yield()执行后线程直接进入就绪状态,马上释放了cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行
join()执行后线程进入阻塞状态,例在线程B调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程
线程安全在三个方面体现:
对该问题的考察其实不是线程安全、而是内存安全,堆是共享内存,可以被所有线程访问
当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的,其中所谓正确的结果也就是单线程运行的结果
堆是进程和线程共有的空间,分全局堆
和局部堆
。全局堆就是所有没有分配的空间,局剖堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。
在Java中,堆是jvm所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
栈是每个线程独有的,保存其运行状态和局部自动变量。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里面显式的分配和释放
目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间——栈,而不能访问别的进程的,这是由操作系统保障的。
每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区或,这就是造成问题的潜在原因。
Thread和Runnable的实质是继承关系,没有可比性。无论使用Runnable还是Thread,都会new Thread,然后执行run方法。用法上,由于Thread实现了Runnable接口,进行了一些功能拓展,因此如果有复杂的线程操作需求,那就选择继承Thread,如果只是简单的执行一个任务,那就实现runnable
守护线程:
守护进程的作用:
GC垃圾回收线程
就是最经典的一个守护线程,它始终在低级别的状态中运行,用于实时监控和管理系统中可回收的资源。当程序中不再有任何运行的线程时,程序就不再产生垃圾,垃圾回收线程就无事可做,所以当GC垃圾回收线程是jvm上仅剩的线程时,它会自动离开。使用场景:
注意:
由于守护线程的终止是自身无法控制的,因此干万不要把Io、File等重要操作逻辑分配给它;因为它不靠谱
thread.setDaemen(true)
必须在thread.start()之前设置,否则会抛出inllegalThreadStateException
异常,不能将正在运行的用户线程转换为守护线程
在守护进程中新产生的线程也是守护线程
守护线程不能用于访问固有资源,比如读写操作/计算逻辑。因为它会在任意时候发生中断
Java自带的多线程框架如ExecutorService
,即使设置为了用户线程,也会自动将守护线程转换为用户线程,所以要使用守护进程不能使用Java的线程池
悲观锁(Pessimistic Lock)
1️⃣ 简介
当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】
悲观锁,正如其名,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。
2️⃣ 实现
3️⃣ 分类
悲观锁主要分为 共享锁
和 排他锁
共享锁
【shared locks】又称为读锁,简称S锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。排他锁
【exclusive locks】又称为写锁,简称X锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据行读取和修改。4️⃣ 说明
悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。
乐观锁(Optimistic Locking)
1️⃣ 简介
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。乐观锁适用于读操作多的场景,这样可以提高程序的吞吐量。
乐观锁机制采取了更加宽松的加锁机制。乐观锁是相对悲观锁而言,也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。
2️⃣ 实现
CAS
实现:Java中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种CAS实现方式
//:这里的第二个参数等同于乐观锁的version,初始值设为1
public AtomicStampedReference(V initialRef, int initialStamp)
版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会+1。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功
3️⃣ 说明
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁
乐观锁和悲观锁哪个好?
两种锁各有优缺点,不可认为一种好于另一种,比如像乐观锁
,适用于写比较少的情况下,冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。
本质上,MySQL的乐观锁与悲观锁主要都是用来解决并发的场景,避免丢失更新问题。
总结
广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。
代码示例:synchronized版
执行结果:
代码示例:Lock版
不断的尝试,直到成功为止!
我们来编写一个自旋锁,利用CAS实现
package 自旋锁;
import java.util.concurrent.atomic.AtomicReference;
//自定义自旋锁
public class SpinLock {
//存放线程的原子引用
AtomicReference<Thread> atomicReference = new AtomicReference<>();
//加锁,需要自旋
public void myLock() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + "==>myLock");
//自旋锁:如果atomicReference为空,则将当前线程存入atomicReference
while (!atomicReference.compareAndSet(null, thread)) ;
}
//解锁,不需要自旋
public void myUnlock() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + "==>myUnLock");
//自旋锁:如果atomicReference为当前线程,则将当前线程置空
atomicReference.compareAndSet(thread, null);
}
}
然后编写一段测试代码
package 自旋锁;
import java.util.concurrent.TimeUnit;
public class Test {
public static void main(String[] args) throws InterruptedException {
SpinLock spinLock = new SpinLock();
//线程T1
new Thread(() -> {
//加锁
spinLock.myLock();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//解锁
spinLock.myUnlock();
}
}, "T1").start();
TimeUnit.SECONDS.sleep(1);
//线程T2
new Thread(() -> {
spinLock.myLock();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.myUnlock();
}
}, "T2").start();
}
}
根据结果,总是T1
线程解锁后,T2
线程才能解锁;因为如果T1
线程不解锁,T2
就会卡住在while循环不停的尝试cas直到thread=null为止
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
简单的死锁案例
package 死锁;
import java.util.concurrent.TimeUnit;
public class DeadLockDemo {
public static void main(String[] args) {
String lockA = "lockA";
String lockB = "lockB";
new Thread(new MyThread(lockA, lockB), "T1").start();
new Thread(new MyThread(lockB, lockA), "T2").start();
}
}
class MyThread implements Runnable {
private String lockA;
private String lockB;
public MyThread(String lockA, String lockB) {
this.lockA = lockA;
this.lockB = lockB;
}
@Override
public void run() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "持有锁" + lockA + "尝试获取" + lockB);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "持有锁" + lockB + "尝试获取" + lockA);
}
}
}
}
根据运行结果,可以看到程序卡死,因为发生了死锁,因为T1和T2分别持有lockA和lockB,但又都试图获取对方的锁!
死锁问题排查
使用jps -l
命令定位进程号
使用jstack 进程号
查看指定进程的堆栈信息,找到死锁问题
可以看到,控制台清晰的打印了找到死锁,并可以看到产生的原因就是T1
和T2
互相尝试获取对方的锁
避免死锁
产生死锁有四个必要条件,只要破坏一种一个或多个条件,就能避免死锁发生
(2条消息) 一篇文章带你搞定并发多线程里的无锁_南淮北安的博客-CSDN博客_java 多线程 无锁
JMM和底层实现原理 - 简书 (jianshu.com)
BAT经典面试题,深入理解Java内存模型JMM
Java 内存模型
Java Memory Model
是一种抽象的概念,并不真实存在,它描述了一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。JMM与JVM内存区域划分的区别:
- JMM描述的是一组规则,围绕原子性、有序性和可见性展开;
- 相似点:存在共享区域和私有区域
JVM中存在一个主存区(Main Memory或Java Heap Memory),Java中所有变量都是存在主存中的,对于所有线程进行共享,而每个线程又存在自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。
但是这也会引入可见性问题:当多个线程同时要修改主内存中同一个变量时,比如线程1在自己的工作内存中将a置为0,线程2在自己的工作内存中将a置为1,这两个线程对a的修改互相是不可见的,因为对a的更改还没有flush到主存中。可以通过volatile
或syschronized
解决这个问题,这样线程私有工作内存中对变量的修改可以立马刷新到主存中。
关于JMM同步的约定:
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。
read
:把一个变量的值从主内存传输到工作内存中load
:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中use
:把工作内存中一个变量的值传递给执行引擎assign
:把一个从执行引擎接收到的值赋给工作内存的变量store
:把工作内存的一个变量的值传送到主内存中write
:在 store 之后执行,把 store 得到的值放入主内存的变量中lock
:作用于主内存的变量unlock
1️⃣ 继承Thread类创建线程类
(1)创建一个类继承Thread类,重写run()方法,将所要完成的任务代码写进run()方法中;
(2)创建Thread类的子类的对象;
(3)调用该对象的start()方法,该start()方法表示先开启线程,然后调用run()方法;
2️⃣ 通过Runnable接口创建线程类
(1)创建一个类并实现Runnable接口
(2)重写run()方法,将所要完成的任务代码写进run()方法中
(3)创建实现Runnable接口的类的对象,将该对象当做Thread类的构造方法中的参数传进去
(4)使用Thread类的构造方法创建一个对象,并调用start()方法即可运行该线程
3️⃣ 通过Callable和Future创建线程
(1)创建一个类并实现Callable接口
(2)重写call()方法,将所要完成的任务的代码写进call()方法中,需要注意的是call()方法有返回值,并且可以抛出异常
(3)创建Callable实现类的实例,再创建Future接口的实现类FutureTask类的对象,使用FutureTask类包装Callanle对象
(4)使用Thread类的有参构造器创建对象,将FutureTask类的对象当做参数传进去,然后调用start()方法开启并运行该线程。
(5)调用FutureTask对象的get()方法可获取call()方法的返回值
package com.zsr;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class dome1 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
myThread myThread = new myThread();
FutureTask<String> futureTask = new FutureTask<String>(myThread);
new Thread(futureTask).start();
System.out.println(futureTask.get());//获取call方法返回值
}
}
class myThread implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("调用call方法");
return "call方法执行完成";
}
}
4️⃣ 通过线程池创建
(1)使用Executors类中的newFixedThreadPool(int num)方法创建一个线程数量为num的线程池
(2)调用线程池中的execute()方法执行由实现Runnable接口创建的线程;调用submit()方法执行由实现Callable接口创建的线程
(3)调用线程池中的shutdown()方法关闭线程池
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPool {
public static void main(String[] args) {
//创建线程池,可指定固定线程数量同时执行
ExecutorService fixThreadPool = Executors.newFixedThreadPool(5);
//使用线程池创建线程
for (int i = 0; i < 20; i++) {
fixThreadPool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " ok");
});
}
//线程池用完,关闭线程池
fixThreadPool.shutdown();
}
}
每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。
start()方法来启动一个线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码; 这时此线程是处于就绪状态, 并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止。然后CPU再调度其它线程。
run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。
newFixedThreadPool(int nThreads)
创建一个固定长度的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化,当线程发生未预期的错误而结束时,线程池会补充一个新的线程。
newCachedThreadPool()
创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的规模不存在任何限制。
newSingleThreadExecutor()
这是一个单线程的Executor,它创建单个工作线程来执行任务,如果这个线程异常结束,会创建一个新的来替代它;它的特点是能确保依照任务在队列中的顺序来串行执行。
newScheduledThreadPool(int corePoolSize)
创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。
接收的参数不一样,execute只可接收Runnable对象,而submit既可接收Runnable对象又可接收Callable对象
submit()方法可以返回持有计算结果的Future对象,而execute()方法的返回类型是void
方法所在的接口不同,execute()定义在Executor接口下,而submit()定义在ExecutorService接口下,扩展了Executor接口
Synchronized
是内置的关键字,Lock
是一个Java类
Synchronized
无法判断锁的状态,Lock
可以判断是否获取到了锁
Synchronized
会自动释放锁,Lock
需要手动释放锁(如果不释放锁,会造成死锁)
假如有两个线程:线程1、线程2;线程1获得了锁
Synchronized
:如果线程1阻塞了,线程2就会一直等待,造成死锁
Lock
:如果线程1阻塞了,线程2不会一直等待,可以通过trylock()
方法尝试获取锁
两者都是可重入锁,但是Synchronized
不可中断,为非公平锁;而Lock
锁可判断锁状态,并且可以设置为公平锁/非公平锁
Synchronized
适合锁少量代码同步代码,Lock
适合锁大量代码同步代码
synchronized是关键字,ReentrantLock是类,这是二者的本质区别。既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:
另外,二者的锁机制其实也是不一样的:ReentrantLock底层调用的是Unsafe的park方法加锁,synchronized操作的应该是对象头中mark word
什么是
Unsafe
类,全名为:sun.misc.Unsafe
- Java无法操作内存,只能通过调用C++来操作内存,
Unsafe
就是Java通过C++操作内存的接口- 就类似于Java通过native关键字来调用C++本地方法来和操作系统交互
java.util.concurrent.atomic
包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
Atomic系列的类中的核心方法都会调用unsafe类中的几个本地方法。我们需要先知道一个东西就是Unsafe类,全名为:sun.misc.Unsafe,这个类包含了大量的对C代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患,需要小心使用,否则会导致严重的后果,例如在通过unsafe分配内存的时候,如果自己指定某些区域可能会导致一些类似C++一样的指针越界到其他进程的问题。