多线程
进程与线程
进程
定义
进程是获得资源后的正在执行的程序的统称
流程
线程
定义
是比进程更小的执行单元,也是动态执行的,一个进程可以有多个线程,被称为轻量级进程。
java中创建线程的几种方式
-
继承Thread类,重写run方法,调用start方法执行
-
实现Runnable接口,实现run方法 ,创建Runnbale对象,new thread(Runnable对象),Runnable的run方法代替thread的run方法负责业务的操作,thread负责资源调度与线程创建辅助真实业务。
@Override public void run() { //传入的target为Runnable if (target != null) { target.run(); } }
继承Thread和实现Runnable接口的区别
- 实现Runnable接口避免多继承局限
- 实现Runnable()可以更好的体现共享的概念
-
实现Callable接口,实现call方法,Callable最大的不同就是有返回值,可以通过future获得返回值
import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class callable implements Callable<String> { private int count = 20; @Override public String call() throws Exception { // Thread.yield(); System.out.println(Thread.currentThread().getName()); return "sale out"; } public static void main(String[] args) throws InterruptedException, ExecutionException { Callable<String> callable =new callable(); FutureTask<String> futureTask=new FutureTask<>(callable); FutureTask<String> futureTask1 = new FutureTask<>(callable); FutureTask<String> futureTask2 = new FutureTask<>(callable); Thread mThread=new Thread(futureTask,"1"); Thread mThread2=new Thread(futureTask1,"2"); Thread mThread3=new Thread(futureTask2,"3"); // mThread.setName("hhh"); mThread.start(); mThread2.start(); mThread3.start(); Thread main = Thread.currentThread(); main.sleep(5000l); System.out.println(futureTask.get()); //2 //1 //3 //sale out } }
-
ThreadExcuter另外拎出来讲,详情见下文
生命周期
JVM理解的进程与线程
在JVM角度中,进程代表正在运行的程序,就是启动一个java时后台的javaplatform.exe类似的程序,进程之间是互相隔离的。JVM里的划分结构可以理解为进程,而线程私有区(程序计数器,虚拟机栈,本地方法栈)可以理解为一个个的线程独占的区域,堆和方法区则是线程共享的区域,可以看到,线程之间是会通过共享区互相影响的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dUA6yj2K-1627550799967)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210727111744573.png)]
并发与并行的概念
- 并发: 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
- 并行: 单位时间内,多个任务同时执行。
线程死锁
死锁的四大条件
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
- 结合实例理解
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
Output
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
线程 A 通过 synchronized (resource1)
获得 resource1
的监视器锁,然后通过Thread.sleep(1000);
让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁
1.resource1、resource2资源任意一个时刻只由一个线程占用。
2.A线程因请求资源而阻塞时,对已获得的resource1保持不放
3.A线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
4.[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T1wMrbgX-1627550799968)(C:\Users\Administrator\Pictures\spring\循环依赖.png)]
线程的一些方法
sleep() 方法和 wait() 方法区别和共同点?
- 两者最主要的区别在于:
sleep()
方法没有释放锁,而wait()
方法释放了锁 。 - 两者都可以暂停线程的执行。
wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)
超时后线程会自动苏醒。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?
- new 一个Thread,线程进入了新建状态,调用start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到 时间片 后就可以开始运行了。start() 会执行线程的相应准备工作,然后自动执行run() 方法的内容,这是真正的多线程工作。
- 而直接执行run() 方法,会把run 方法当成一个main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总的来说:调用start 方法方可启动线程并使线程进入就绪状态,而run 方法只是thread 的一个普通方法调用,还是在主线程里执行。
进程与线程的区别
一句话总结就是:
线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
synchronized
说一说自己对于 synchronized 关键字的了解
synchronized
关键字解决的是多个线程之间访问资源的同步性,synchronized
关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
另外,在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。
为什么呢?
因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized
较大优化,所以现在的 synchronized
锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized
关键字。
说说自己是怎么使用 synchronized 关键字
synchronized 关键字最主要的三种使用方式:
1.修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
synchronized void method() {
//业务代码
}
2.修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
synchronized static void method() {
//业务代码
}
3.修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object)
表示进入同步代码库前要获得给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 当前 class 的锁
synchronized(this) {
//业务代码
}
总结:
synchronized
关键字加到static
静态方法和synchronized(class)
代码块上都是是给 Class 类上锁。synchronized
关键字加到实例方法上是给对象实例上锁。- 尽量不要使用
synchronized(String a)
因为 JVM 中,字符串常量池具有缓存功能!
下面我以一个常见的面试题为例讲解一下 synchronized
关键字的具体使用。
面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”
双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
另外,需要注意 uniqueInstance
采用 volatile
关键字修饰也是很有必要。
uniqueInstance
采用 volatile
关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为
uniqueInstance
分配内存空间 - 初始化
uniqueInstance
- 将
uniqueInstance
指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance
() 后发现 uniqueInstance
不为空,因此返回 uniqueInstance
,但此时 uniqueInstance
还未被初始化。
使用 volatile
可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
构造方法可以使用 synchronized 关键字修饰么?
构造方法不能使用 synchronized 关键字修饰。
构造方法本身就属于线程安全的,不存在同步的构造方法一说。
调用构造方法属于创建对象,创建对象会检查类有没有加载,没有就先加载类。如果多线程的去创建对象,JVM会分配不同的内存去初始化对象,不存在多线程问题。
对于
<clinit> ()
方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为<clinit> ()
方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个进程阻塞,并且这种阻塞很难被发现。
讲一下 synchronized 关键字的底层原理
synchronized 关键字底层原理属于 JVM 层面。
synchronized 同步语句块的情况
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过 JDK 自带的 javap
命令查看 SynchronizedDemo
类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java
命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class
。
从上面我们可以看出:
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
当执行 monitorenter
指令时,线程试图获取锁也就是获取 对象监视器 monitor
的持有权。
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个
ObjectMonitor
对象。另外,
wait/notify
等方法也依赖于monitor
对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify
等方法,否则会抛出java.lang.IllegalMonitorStateException
的异常的原因。
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
在执行 monitorexit
指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
总结
synchronized
同步语句块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。
synchronized
修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。
不过两者的本质都是对对象监视器 monitor 的获取。
JDK1.6 之后的 synchronized 关键字底层做了哪些优化
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
无锁、偏向锁、轻量级锁、重量级锁
- 先了解以下Java对象头(存储锁类型)
在HotSpot虚拟机中, 对象在内存中的布局分为三块区域: 对象头, 示例数据和对其填充.
对象头中包含两部分: MarkWord 和 类型指针.
如果是数组对象的话, 对象头还有一部分是存储数组的长度.
多线程下synchronized的加锁就是对同一个对象的对象头中的MarkWord中的变量进行CAS操作.
MarkWord
Mark Word用于存储对象自身的运行时数据, 如HashCode, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID等等.
占用内存大小与虚拟机位长一致(32位JVM -> MarkWord是32位, 64位JVM->MarkWord是64位).
类型指针
类型指针指向对象的类元数据, 虚拟机通过这个指针确定该对象是哪个类的实例.
锁可以升级, 但不能降级. 即: 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的.
下面看一下每个锁状态时, 对象头中的MarkWord这一个字节中的内容是什么. 以32位为例.
无锁状态
25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁标志位) |
---|---|---|---|
对象的hashCode | 对象分代年龄 | 0 | 01 |
偏向锁状态
23bit | 2bit | 4bit | 1bit | 2bit |
---|---|---|---|---|
线程ID | epoch | 对象分代年龄 | 1 | 01 |
轻量级锁状态
30bit | 2bit |
---|---|
指向栈中锁记录的指针 | 00 |
重量级锁状态
30bit | 2bit |
---|---|
指向互斥量(重量级锁)的指针 | 10 |
锁的升级(进化)
偏向锁
偏向锁是针对于一个线程而言的, 线程获得锁之后就不会再有解锁等操作了, 这样可以省略很多开销. 假如有两个线程来竞争该锁话, 那么偏向锁就失效了, 进而升级成轻量级锁了.
为什么要这样做呢? 因为经验表明, 其实大部分情况下, 都会是同一个线程进入同一块同步代码块的. 这也是为什么会有偏向锁出现的原因.
在Jdk1.6中, 偏向锁的开关是默认开启的, 适用于只有一个线程访问同步块的场景.
偏向锁的加锁
- 首先读取目标对象的 MarkWord, 判断是否处于可偏向的状态(如下图)
-
如果为可偏向状态, 则尝试用 CAS 操作, 将自己的线程 ID 写入MarkWord
如果 CAS 操作成功(状态转变为下图), 则认为已经获取到该对象的偏向锁, 执行同步块代码 。 注意, age 后面的标志位中的值并没有变化, 这点之后会用到-
![这里写图片描述](https://img-blog.csdn.net/20180813165754520?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2xlbmd4aWFvMTk5Mw==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70)
-
补充: 一个线程在执行完同步代码块以后, 并不会尝试将 MarkWord 中的 thread ID 赋回原值 。这样做的好处是: 如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下, 即可在不修改对象头的情况下, 直接认为偏向成功。
-
-
如果 CAS 操作失败, 则说明, 有另外一个线程 Thread B 抢先获取了偏向锁。 这种状态说明该对象的竞争比较激烈, 此时需要撤销 Thread B 获得的偏向锁,将 Thread B 持有的锁升级为轻量级锁。 该操作需要等待全局安全点 JVM safepoint ( 此时间点, 没有线程在执行字节码)
-
如果是已偏向状态, 则检测 MarkWord 中存储的 thread ID 是否等于当前 thread ID 。
如果相等, 则证明本线程已经获取到偏向锁, 可以直接继续执行同步代码块
如果不等, 则证明该对象目前偏向于其他线程, 需要撤销偏向锁从上面的偏向锁机制描述中,可以注意到
偏向锁的 撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), 此时间点所有的工作线程都停止了字节码的执行。
总结:当一个线程访问同步块并获取锁时, 会在锁对象的对象头和栈帧中的锁记录里存储锁偏向的线程ID, 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 只需要简单的比较一下锁对象的对象头的MarkWord里是否存储着当前线程的线程id,
如果相等, 表示线程已经获得了锁,直接执行代码块; 如果测试失败, 则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁), 如果没有设置, 则使用CAS竞争锁, 如果设置了, 则尝试使用CAS将锁对象的对象头的线程id指向当前线程。
如果不相等,就偏向锁撤销,升级为轻量级锁。
偏向锁的撤销(Revoke)
如上文提到的, 偏向锁的撤销(Revoke) 操作并不是将对象恢复到无锁可偏向的状态, 而是在偏向锁的获取过程中, 发现了竞争时, 直接将一个被偏向的对象“升级到” 被加了轻量级锁的状态。 这个操作的具体完成方式如下:
在偏向锁 CAS 更新操作失败以后, 等待到达全局安全点。
-
通过 MarkWord 中已经存在的 Thread Id 找到成功获取了偏向锁的那个线程, 然后在该线程的栈帧中补充上轻量级加锁时, 会保存的锁记录(Lock Record), 然后将被获取了偏向锁对象的 MarkWord 更新为指向这条锁记录的指针。
-
至此, 锁撤销操作完成, 阻塞在安全点的线程可以继续执行
偏向锁的批量再偏向(Bulk Rebias)机制
偏向锁这个机制很特殊, 别的锁在执行完同步代码块后, 都会有释放锁的操作, 而偏向锁并没有直观意义上的“释放锁”操作。
那么作为开发人员, 很自然会产生的一个问题就是, 如果一个对象先偏向于某个线程, 执行完同步代码后, 另一个线程就不能直接重新获得偏向锁吗? 答案是可以, JVM 提供了批量再偏向机制(Bulk Rebias)机制
该机制的主要工作原理如下:
- 一个概念 epoch, 其本质是一个时间戳 , 代表了偏向锁的有效性
- 文描述的对象头结 构中可以看到, epoch 存储在可偏向对象的 MarkWord 中。
- 对象中的 epoch, 对象所属的类 class 信息中, 也会保存一个 epoch 值
- 遇到一个全局安全点时, 如果要对 class C 进行批量再偏向, 则首先对 class C 中保存的 epoch 进行增加操作, 得到一个新的 epoch_new
- 扫描所有持有 class C 实例的线程栈, 根据线程栈的信息判断出该线程是否锁定了该对象, 仅将 epoch_new 的值赋给被锁定的对象中。
- 安全点后, 当有线程需要尝试获取偏向锁时, 直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等, 则说明该对象的偏向锁已经无效了, 可以尝试对此对象重新进行偏向操作。
偏向锁在Java6及更高版本中是默认启用的, 但是它在程序启动几秒钟后才激活. 可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁, 那么程序会直接进入轻量级锁状态.
轻量级锁
当出现有两个线程来竞争锁的话, 那么偏向锁就失效了, 此时锁就会膨胀, 升级为轻量级锁.
轻量级锁加锁
-
在当前线程的栈桢(Stack Frame)中创建用于存储锁记录(lock record)的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。如果在此过程中发现,
-
然后线程尝试使用 CAS 操作将对象头中的 Mark Word 替换为指向锁记录的指针。
-
成功,当前线程获得锁
-
失败,表示该对象已经被加锁了, 先进行自旋操作, 再次尝试 CAS 争抢, 如果仍未争抢到, 则进一步升级锁至重量级锁。
偏向锁状态
23bit 2bit 4bit 1bit 2bit 线程ID epoch 对象分代年龄 1 01 轻量级锁状态
30bit 2bit 指向栈中锁记录的指针 00
轻量级锁解锁
-
对象头中的Mark World中锁记录指针是否仍然指向当前线程锁记录
-
拷贝在当前线程所记录中的Mark World信息是否与锁对象头中的Mark World信息一样
是则解锁成功
不是则代表有其他线程竞争锁,将锁升级为重量级锁,并且唤醒释放锁,唤醒等待的线程,让他们重新争夺锁
-
还有一种情况轻量级锁会变成重量级锁
就是一个线程获取轻量级锁不成功时,会自旋重复获取锁,如果自旋的次数超过了一定次数,锁就会转为重量级锁。
流程分析:
总结一下加锁解锁过程, 有线程A和线程B来竞争对象c的锁(如: synchronized©{} ), 这时线程A和线程B同时将对象c的MarkWord复制到自己的锁记录中, 两者竞争去获取锁, 假设线程A成功获取锁, 并将对象c的对象头中的线程ID(MarkWord中)修改为指向自己的锁记录的指针, 这时线程B仍旧通过CAS去获取对象c的锁, 因为对象c的MarkWord中的内容已经被线程A改了, 所以获取失败. 此时为了提高获取锁的效率, 线程B会循环去获取锁, 这个循环是有次数限制的, 如果在循环结束之前CAS操作成功, 那么线程B就获取到锁, 如果循环结束依然获取不到锁, 则获取锁失败, 对象c的MarkWord中的记录会被修改为重量级锁, 然后线程B就会被挂起, 之后有线程C来获取锁时, 看到对象c的MarkWord中的是重量级锁的指针, 说明竞争激烈, 直接挂起.
解锁时, 线程A尝试使用CAS将对象c的MarkWord改回自己栈中复制的那个MarkWord, 因为对象c中的MarkWord已经被指向为重量级锁了, 所以CAS失败. 线程A会释放锁并唤起等待的线程, 进行新一轮的竞争.
最后附上一张大体的流程图
锁的比较
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗, 和执行非同步代码方法的性能相差无几. | 如果线程间存在锁竞争, 会带来额外的锁撤销的消耗. | 适用于只有一个线程访问的同步场景 |
轻量级锁 | 竞争的线程不会阻塞, 提高了程序的响应速度 | 如果始终得不到锁竞争的线程, 使用自旋会消耗CPU | 追求响应时间, 同步快执行速度非常快 |
重量级锁 | 线程竞争不适用自旋, 不会消耗CPU | 线程堵塞, 响应时间缓慢 | 追求吞吐量, 同步快执行时间速度较长 |
总结
偏向锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
synchronized 和 ReentrantLock 的区别
两者都是可重入锁
“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized
是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized
,ReentrantLock
增加了一些高级功能。主要来说主要有三点:
- 等待可中断 :
ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。 - 可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。 - 可实现选择性通知(锁可以绑定多个条件):
synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
Condition
是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock
对象中可以创建多个Condition
实例(即对象监视器),线程对象可以注册在指定的Condition
中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()
方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock
类结合Condition
实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized
关键字就相当于整个 Lock 对象中只有一个Condition
实例,所有的线程都注册在它一个身上。如果执行notifyAll()
方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition
实例的signalAll()
方法 只会唤醒注册在该Condition
实例中的所有等待线程。
如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。性能已不是选择标准
volatile
volatile既然是与线程安全有关的问题,那我们先来了解一下计算机在处理数据的过程中为什么会出现线程不安全的问题。
大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。
为了处理这个问题,在CPU里面就有了高速缓存(Cache)的概念。当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
我举个简单的例子,比如cpu在执行下面这段代码的时候,
t = t + 1;
会先从高速缓存中查看是否有t的值,如果有,则直接拿来使用,如果没有,则会从主存中读取,读取之后会复制一份存放在高速缓存中方便下次使用。之后cup进行对t加1操作,然后把数据写入高速缓存,最后会把高速缓存中的数据刷新到主存中。
这一过程在单线程运行是没有问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的,本次讲解以多核cup为主)。这时就会出现同一个变量在两个高速缓存中的值不一致问题了。
例如:
两个线程分别读取了t的值,假设此时t的值为0,并且把t的值存到了各自的高速缓存中,然后线程1对t进行了加1操作,此时t的值为1,并且把t的值写回到主存中。但是线程2中高速缓存的值还是0,进行加1操作之后,t的值还是为1,然后再把t的值写回主存。
此时,就出现了线程不安全问题了。
Java中的线程安全问题
上面那种线程安全问题,可能对于不同的操作系统会有不同的处理机制,例如Windows操作系统和Linux的操作系统的处理方法可能会不同。
我们都知道,Java是一种夸平台的语言,因此Java这种语言在处理线程安全问题的时候,会有自己的处理机制,例如volatile关键字,synchronized关键字,并且这种机制适用于各种平台。
Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
由于java中的每个线程有自己的工作空间,这种工作空间相当于上面所说的高速缓存,因此多个线程在处理一个共享变量的时候,就会出现线程安全问题。
这里简单解释下共享变量,上面我们所说的t就是一个共享变量,也就是说,能够被多个线程访问到的变量,我们称之为共享变量。在java中共享变量包括实例变量,静态变量,数组元素。他们都被存放在堆内存中。
jvm主内存与工作内存
- 首先,JVM将内存组织为主内存和工作内存两个部分。
- 主内存主要包括本地方法区和堆。每个线程都有一个工作内存,工作内存中主要包括两个部分,一个是属于该线程私有的栈和对主存部分变量拷贝的寄存器(包括程序计数器PC和cup工作的高速缓存区)。
1.所有的变量都存储在主内存中(虚拟机内存的一部分),对于所有线程都是共享的。
2.每条线程都有自己的工作内存,工作内存中保存的是主存中某些变量的拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
3.线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成。
volatile作用
上面扯了一大堆,都没提到volatile关键字的作用,下面开始讲解volatile关键字是如何保证线程安全问题的。
并发编程的三个重要特性
- 原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。
synchronized
可以保证代码片段的原子性。 - 可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
volatile
关键字可以保证共享变量的可见性。 - 有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。
volatile
关键字可以禁止指令进行重排序优化
可见性
什么是可见性?
意思就是说,在多线程环境下,某个共享变量如果被其中一个线程给修改了,其他线程能够立即知道这个共享变量已经被修改了,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取。
例如我们上面说的,当线程1对t进行了加1操作并把数据写回到主存之后,线程2就会知道它自己工作空间内的t已经被修改了,当它要执行加1操作之后,就会去主存中读取。这样,两边的数据就能一致了。
假如一个变量被声明为volatile,那么这个变量就具有了可见性的性质了。这就是volatile关键的作用之一了。
volatile保证变量可见性的原理
当一个变量被声明为volatile时,在编译成会变指令的时候,会多出下面一行:
0x00bbacde: lock add1 $0x0,(%esp);
这句指令的意思就是在寄存器执行一个加0的空操作。不过这条指令的前面有一个lock(锁)前缀。
当处理器在处理拥有lock前缀的指令时:
在之前的处理中,lock会导致传输数据的总线被锁定,其他处理器都不能访问总线,从而保证处理lock指令的处理器能够独享操作数据所在的内存区域,而不会被其他处理所干扰。
但由于总线被锁住,其他处理器都会被堵住,从而影响了多处理器的执行效率。为了解决这个问题,在后来的处理器中,处理器遇到lock指令时不会再锁住总线,而是会检查数据所在的内存区域,如果该数据是在处理器的内部缓存中,则会锁定此缓存区域,处理完后把缓存写回到主存中,并且会利用缓存一致性协议来保证其他处理器中的缓存数据的一致性。
缓存一致性协议
刚才我在说可见性的时候,说“如果一个共享变量被一个线程修改了之后,当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从自己的工作空间中读取”,实际上是这样的:
线程中的处理器会一直在总线上嗅探其内部缓存中的内存地址在其他处理器的操作情况,一旦嗅探到某处处理器打算修改其内存地址中的值,而该内存地址刚好也在自己的内部缓存中,那么处理器就会强制让自己对该缓存地址的无效。所以当该处理器要访问该数据的时候,由于发现自己缓存的数据无效了,就会去主存中访问。
有序性
实际上,当我们把代码写好之后,虚拟机不一定会按照我们写的代码的顺序来执行。例如对于下面的两句代码:
int a = 1;
int b = 2;
对于这两句代码,你会发现无论是先执行a = 1还是执行b = 2,都不会对a,b最终的值造成影响。所以虚拟机在编译的时候,是有可能把他们进行重排序的。
为什么要进行重排序呢?
你想啊,假如执行 int a = 1这句代码需要100ms的时间,但执行int b = 2这句代码需要1ms的时间,并且先执行哪句代码并不会对a,b最终的值造成影响。那当然是先执行int b = 2这句代码了。
所以,虚拟机在进行代码编译优化的时候,对于那些改变顺序之后不会对最终变量的值造成影响的代码,是有可能将他们进行重排序的。
更多代码编译优化可以看我写的另一篇文章:
虚拟机在运行期对代码的优化策略
那么重排序之后真的不会对代码造成影响吗?
实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题的。具体请看下面的代码
public class NoVisibility{
private static boolean ready;
private static int number;
private static class Reader extends Thread{
public void run(){
while(!ready){
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args){
new Reader().start();
number = 42;
ready = true;
}
}
这段代码最终打印的一定是42吗?如果没有重排序的话,打印的确实会是42,但如果number = 42和ready = true被进行了重排序,颠倒了顺序,那么就有可能打印出0了,而不是42。(因为number的初始值会是0).
因此,重排序是有可能导致线程安全问题的。
如果一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。
例如把上面中的number声明为volatile,那么number = 42一定会比ready = true先执行。
不过这里需要注意的是,虚拟机只是保证这个变量之前的代码一定比它先执行,但并没有保证这个变量之前的代码不可以重排序。之后的也一样。
volatile关键字能够保证代码的有序性,这个也是volatile关键字的作用。
总结一下,一个被volatile声明的变量主要有以下两种特性保证保证线程安全。
- 可见性。
- 有序性。
volatile真的能完全保证一个变量的线程安全吗?
我们通过上面的讲解,发现volatile关键字还是挺有用的,不但能够保证变量的可见性,还能保证代码的有序性。
那么,它真的能够保证一个变量在多线程环境下都能被正确的使用吗?
答案是否定的。原因是因为Java里面的运算并非是原子操作。
原子操作
原子操作:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
也就是说,处理器要嘛把这组操作全部执行完,中间不允许被其他操作所打断,要嘛这组操作不要执行。
刚才说Java里面的运行并非是原子操作。我举个例子,例如这句代码
int a = b + 1;
处理器在处理代码的时候,需要处理以下三个操作:
- 从内存中读取b的值。
- 进行a = b + 1这个运算
- 把a的值写回到内存中
而这三个操作处理器是不一定就会连续执行的,有可能执行了第一个操作之后,处理器就跑去执行别的操作的。
证明volatile无法保证线程安全的例子
由于Java中的运算并非是原子操作,所以导致volatile声明的变量无法保证线程安全。
对于这句话,我给大家举个例子。代码如下:
public class Test{
public static volatile int t = 0;
public static void main(String[] args){
Thread[] threads = new Thread[10];
for(int i = 0; i < 10; i++){
//每个线程对t进行1000次加1的操作
threads[i] new Thread(new Runnable(){
@Override
public void run(){
for(int j = 0; j < 1000; j++){
t = t + 1;
}
}
});
threads[i].start();
}
//等待所有累加线程都结束
while(Thread.activeCount() > 1){
Thread.yield();
}
//打印t的值
System.out.println(t);
}
}
最终的打印结果会是1000 * 10 = 10000吗?答案是否定的。
问题就出现在t = t + 1这句代码中。我们来分析一下
例如:
线程1读取了t的值,假如t = 0。之后线程2读取了t的值,此时t = 0。
然后线程1执行了加1的操作,此时t = 1。但是这个时候,处理器还没有把t = 1的值写回主存中。这个时候处理器跑去执行线程2,注意,刚才线程2已经读取了t的值,所以即便遵循一致性协议去主存找t值,由于线程1没有将更改的值刷到主存中,这个时候读取t的值还是一样,所以此时t的值还是0,然后线程2执行了对t的加1操作,此时t =1 。
这个时候,就出现了线程安全问题了,两个线程都对t执行了加1操作,但t的值却是1。所以说,volatile关键字并不一定能够保证变量的安全性。
什么情况下volatile能够保证线程安全
刚才虽然说,volatile关键字不一定能够保证线程安全的问题,其实,在大多数情况下volatile还是可以保证变量的线程安全问题的。所以,在满足以下两个条件的情况下,volatile就能保证变量的线程安全问题:
-
运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
-
变量不需要与其他状态变量共同参与不变约束。
说说 synchronized 关键字和 volatile 关键字的区别
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
-
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。 -
volatile
关键字能保证数据的可见性和有序性,但不能保证数据的原子性。synchronized
关键字三者都能保证。,但是synchronized不能保证指令重排为啥synchronized无法禁止指令重排,但可以保证有序性?
加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞。所以同一时间只有一个线程执行,相当于单线程,而单线程的指令重排是没有问题的。关于volatile和synchronized的关系
-
volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性。
ThreadLocal
线程池
Atomic原子类
原理分析:
原子类的作用是多线程环境下,对于变量的操作是安全的。
注意:并不是指具有原子性
实现原子操作底层使用了大量的CAS和 volatile 和 native 方法保证并发编程的安全
下面以AtomicInteger类分析:
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
这里的this指的是当前对象,valueOffset指的是要修改的属性的内存偏差
可以认为this+valueOffset可以找到在JVM主存中的属性的值
public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);//内存中的值
} while(!this.compareAndSwapInt(var1, var2, var5, var4));
return var5;
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
Unsafe的compareAndSwapInt是个native方法,也就是平台相关的。它是基于CPU的CAS指令来完成的,所以其实是基于操作系统的实现去做的原子性CAS操作,java只是调用C++的函数库去调用操作系统底层的CAS。
AQS
类分析:
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
这里的this指的是当前对象,valueOffset指的是要修改的属性的内存偏差
可以认为this+valueOffset可以找到在JVM主存中的属性的值
public final int getAndSetInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);//内存中的值
} while(!this.compareAndSwapInt(var1, var2, var5, var4));
return var5;
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
Unsafe的compareAndSwapInt是个native方法,也就是平台相关的。它是基于CPU的CAS指令来完成的,所以其实是基于操作系统的实现去做的原子性CAS操作,java只是调用C++的函数库去调用操作系统底层的CAS。