3.线程同步
3.1 线程同步机制简介
线程同步机制是一套用于协调线程之间的数据访问的机制,该机制可以保障线程安全。
Java 平台提供的线程同步机制包括:锁、volatile 关键字、final 关键字、static 关键字,以及相关的 API,如 Object.wait()、Object.notify()等。
3.2 锁概述
线程安全问题的产生前提是多个线程并发访问共享数据,将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,锁就是复用这种思路来保障线程安全的。
锁(Lock)可以理解为对共享数据进行保护的一个许可证,对于同一个许可证保护的共享数据来说,任何线程想要访问这些共享数据必须先持有该许可证,一个线程只有在持有许可证的情况下才能对这些共享数据进行访问;并且一个许可证一次只能被一个线程持有,拥有许可证的线程在结束对共享数据的访问后必须释放其持有的许可证。
一个线程在访问共享数据前必须先获得锁,获得锁的线程称为锁的持有线程;一个锁一次只能被一个线程持有,锁的持有线程在获得锁之后,和释放锁之前这段时间所执行的代码称为临界区(CriticalSection)。
锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有,这种锁称为排它锁或互斥锁(Mutex)。
JVM 把锁分为内部锁和显示锁两种,内部锁通过 synchronized 关键字实现;显示锁通过 java.concurrent.locks.Lock 接口的实现类实现的。
3.2.1 锁的作用
锁可以实现对共享数据的安全访问,保障线程的原子性、可见性与有序性。
锁是通过互斥保障原子性,一个锁只能被一个线程持有,这就保证临界区的代码一次只能被一个线程执行,使得临界区代码所执行的操作自然而然的具有不可分割的特性,即具备了原子性。
可见性的保障是通过写线程冲刷处理器的缓存和读线程刷新处理器缓存这两个动作实现的,在 java 平台中,锁的获得隐含着刷新处理器缓存的动作,锁的释放隐含着冲刷处理器缓存的动作。
锁能够保障有序性,写线程在临界区所执行的,在读线程所执行的临界区看起来像是完全按照源码顺序执行的。
注意:
使用锁保障线程的安全性,必须满足以下条件:
这些线程在访问共享数据时必须使用同一个锁
即使是读取共享数据的线程也需要使用同步锁
3.2.2 锁相关的概念
1.可重入性
可重入性(Reentrancy)描述这样一个问题:一个线程持有该锁的时候能再次(多次)申请该锁。
void methodA() { // 申请 a 锁 methodB(); // 释放 a 锁 } void methodB() { // 申请 a 锁 .... // 释放 a 锁 }
如果一个线程持有一个锁的时候还能够继续成功申请该锁,则称该锁是可重入的,否则就称该锁为不可重入的。
2.锁的争用与调度
Java 平台中内部锁属于非公平锁, 显示 Lock 锁既支持公平锁又支持非公平锁
3.锁的粒度
一个锁可以保护的共享数据的数量大小称为锁的粒度。锁保护共享数据量大,称该锁的粒度粗;否则就称该锁的粒度细。
锁的粒度过粗会导致线程在申请锁时会进行不必要的等待,锁的粒度过细会增加锁调度的开销。
3.3 synchronized 关键字
Java 中的每个对象都有一个与之关联的内部锁,这种锁也称为监视器(Monitor)。这种内部锁是一种排他锁,可以保障原子性、可见性与有序性。
内部锁是通过 synchronized 关键字实现的,synchronized 关键字修饰代码块,修饰实例方法。
关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时 synchronized 可以保证一个线程的变化可见(可见性),即可以代替 volatile
1.普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁 2.静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁 3.同步代码块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁 synchronized( 对象锁 ) { // 同步代码块,可以在同步代码块中访问共享数据 }
Synchronized是通过对象内部的一个叫监视器锁来实现的,但是监视器锁本质又是依赖底层的操作系统的Mutex(互斥) Lock来实现的,而操系统实现线程之间的切换回造成带量得CPU资源浪费,这个成本非常的高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因,因此这种依赖于操作系统Mutex Lock所实现的锁我们称之为:重量级锁。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用,JDK1.5以后,为来减少获得锁和释放锁所带来的性能消耗,引入了:"轻量级锁"和"偏向锁"。
Synchronized 属于最基本的线程通信机制,基于对象监视器实现的。Java中的每个对象都与一个监视器相关联,一个线程可以锁定或解锁。一次只有一个线程可以锁定监视器。试图锁定该监视器的任何其他线程都会被阻塞,直到它们可以获得该监视器上的锁定为止。
3.3.1 synchronized 同步代码块
/** * synchronized 同步代码块 * this 锁对象 */ public class Test01 { public static void main(String[] args) { //创建两个线程,分别调用 mm()方法 //先创建 Test01 对象,通过对象名调用 mm()方法 Test01 obj = new Test01(); new Thread(new Runnable() { @Override public void run() { // 使用的锁对象 this 就是 obj对象 obj.mm(); } }).start(); new Thread(new Runnable() { @Override public void run() { // 使用的锁对象 this 也是 obj对象 obj.mm(); } }).start(); } /** * 使用同步代码块 */ public void mm() { // 使用this当前对象作为锁对象 synchronized (this) { for (int i = 1; i <= 10; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } }
运行结果:
使用不同锁对象:
/** * synchronized 同步代码块 * 如果线程的锁不同, 不能实现同步;想要同步必须使用同一个锁对象 */ public class Test02 { public static void main(String[] args) { //创建两个线程,分别调用 mm()方法 //创建Test02类的 obj 和 obj2 两个对象,通过对象名调用 mm()方法 Test02 obj = new Test02(); Test02 obj2 = new Test02(); new Thread(new Runnable() { @Override public void run() { //使用的锁对象 this 就是 obj 对象 obj.mm(); } }).start(); new Thread(new Runnable() { @Override public void run() { // 使用的锁对象 this 是 obj2对象 obj2.mm(); } }).start(); } public void mm() { synchronized (this) { for (int i = 1; i <= 100; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } }
使用同一个常量对象作为锁对象:
/** * synchronized 同步代码块 * * 使用一个常量对象作为锁对象 */ public class Test03 { public static void main(String[] args) { Test03 obj = new Test03(); Test03 obj2 = new Test03(); new Thread(new Runnable() { @Override public void run() { obj.mm(); //使用的锁对象 OBJ 常量 } }).start(); new Thread(new Runnable() { @Override public void run() { obj2.mm(); //使用的锁对象 OBJ 常量 } }).start(); } // 定义一个常量对象 public static final Object OBJ = new Object(); public void mm() { synchronized (OBJ) { // 使用一个常量对象作为锁对象 for (int i = 1; i <= 10; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } }
/** * synchronized 同步代码块 * * 使用一个常量对象作为锁对象,不同方法中的同步代码块也可以同步 */ public class Test04 { public static void main(String[] args) { Test04 obj = new Test04(); Test04 obj2 = new Test04(); new Thread(new Runnable() { @Override public void run() { obj.mm(); //使用的锁对象 OBJ 常量 } }).start(); new Thread(new Runnable() { @Override public void run() { obj2.mm(); //使用的锁对象 OBJ 常量 } }).start(); // 第三个线程调用静态方法 new Thread(new Runnable() { @Override public void run() { sm(); //使用的锁对象 OBJ 常量 } }).start(); } // 定义一个常量对象 public static final Object OBJ = new Object(); public void mm() { synchronized (OBJ) { //使用一个常量对象作为锁对象 for (int i = 1; i <= 5; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } public static void sm() { synchronized (OBJ) { //使用一个常量对象作为锁对象 for (int i = 1; i <= 5; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } }
运行结果:
3.3.2 同步方法
/** * synchronized 同步实例方法 * * 把整个方法体作为同步代码块 * 默认的锁对象是 this 对象 */ public class Test05 { public static void main(String[] args) { //先创建 Test05 对象,通过对象名调用 mm()方法 Test05 obj = new Test05(); //一个线程调用 mm()方法 new Thread(new Runnable() { @Override public void run() { obj.mm(); //使用的锁对象 this 就是 obj对象 } }).start(); // 另一个线程调用 mm22()方法 new Thread(new Runnable() { @Override public void run() { obj.mm22(); //使用的锁对象 this 也是 obj 对象, 可以同步 } }).start(); } public void mm() { synchronized (this) { //使用 this当前对象作为锁对象 for (int i = 1; i <= 10; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } //使用 synchronized 修饰实例方法,同步实例方法,默认 this 作为 锁对象 public synchronized void mm22() { for (int i = 1; i <= 10; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } }
运行结果:
3.3.3 同步静态方法
/** * synchronized 同步静态方法 * * 把整个方法体作为同步代码块 * 默认的锁对象是当前类的运行时类对象Test06.class, 有人称它为类锁 */ public class Test06 { public static void main(String[] args) { Test06 obj = new Test06(); // 一个线程调用 m1()方法 new Thread(new Runnable() { @Override public void run() { obj.m1(); //使用的锁对象是 Test06.class } }).start(); // 另一个线程调用 sm2()方法 new Thread(new Runnable() { @Override public void run() { Test06.sm2(); //使用的锁对象是 Test06.class } }).start(); } public void m1() { //使用当前类的运行时类对象作为锁对象,可以简单的理解为把 Test06 类的字节码文件作为锁对象 synchronized (Test06.class) { for (int i = 1; i <= 100; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } } //使用 synchronized 修饰静态方法,同步静态方法, 默认运行时类Test06.class 作为锁对象 public synchronized static void sm2() { for (int i = 1; i <= 100; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } }
运行结果:
3.3.4 同步代码块与同步方法执行效率对比
/** * 同步方法与同步代码块如何选择 * * 同步方法锁的粒度粗, 执行效率低, 同步代码块执行效率高 */ public class Test07 { public static void main(String[] args) { Test07 obj = new Test07(); new Thread(new Runnable() { @Override public void run() { // obj.doLongTimeTask(); obj.doLongTimeTask2(); } }).start(); new Thread(new Runnable() { @Override public void run() { // obj.doLongTimeTask(); obj.doLongTimeTask2(); } }).start(); } //同步方法, 粒度粗, 执行效率低 public synchronized void doLongTimeTask() { try { System.out.println("Task Begin"); Thread.sleep(3000); //模拟任务需要准备 3 秒钟 System.out.println("开始同步"); for (int i = 1; i <= 5; i++) { System.out.println(Thread.currentThread().getName() + "-->" + i); } System.out.println("Task end"); } catch (InterruptedException e) { e.printStackTrace(); } } //同步代码块,锁的粒度细, 执行效率高 public void doLongTimeTask2() { try { System.out.println("Task Begin"); Thread.sleep(3000); //模拟任务需要准备 3 秒钟 synchronized (this) { System.out.println("开始同步"); for (int i = 1; i <= 5; i++) { System.out.println(Thread.currentThread().getName() + "-->" + i); } } System.out.println("Task end"); } catch (InterruptedException e) { e.printStackTrace(); } } }
3.3.5 死锁
/** * 死锁 * 在多线程程序中,同步时可能需要使用多个锁,如果获得锁的顺序不一致,可能会导致死锁 * 如何避免死锁? * 当需要获得多个锁时,所有线程获得锁的顺序保持一致即可 */ public class Test10 { public static void main(String[] args) { SubThread t1 = new SubThread(); t1.setName("a"); t1.start(); SubThread t2 = new SubThread(); t2.setName("b"); t2.start(); } static class SubThread extends Thread { private static final Object lock1 = new Object(); private static final Object lock2 = new Object(); @Override public void run() { if ("a".equals(Thread.currentThread().getName())) { synchronized (lock1) { System.out.println("a 线程获得了 lock1 锁,还需要获得 lock2 锁"); synchronized (lock2) { System.out.println("a 线程获得 lock1 后又获得了 lock2,可以想干任何想干的事"); } } } if ("b".equals(Thread.currentThread().getName())) { synchronized (lock2) { System.out.println("b 线程获得了 lock2 锁,还需要获得 lock1 锁"); synchronized (lock1) { System.out.println("b 线程获得 lock2后又获得了 lock1,可以想干任何想干的事"); } } } } } }
3.3.5 线程出现异常会自动释放锁
/** * 同步过程中线程出现异常, 会自动释放锁对象 * */ public class Test09 { public static void main(String[] args) { Test09 obj = new Test09(); //一个线程调用 m1()方法 new Thread(new Runnable() { @Override public void run() { obj.m1(); //使用的锁对象是 Test06.class } }).start(); //另一个线程调用 sm2()方法 new Thread(new Runnable() { @Override public void run() { Test09.sm2(); //使用的锁对象是 Test06.class } }).start(); } public void m1() { //使用当前类的运行时类对象作为锁对象,可以简单的理解为把 Test06 类的字节码文件作为锁对象 synchronized (Test09.class) { for (int i = 1; i <= 10; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); if (i == 5) { //把字符串转换为int 类型时,如果字符串不符合 数字格式会产生异常 Integer.parseInt("abc"); } } } } //使用 synchronized 修饰静态方法,同步静态方法, 默认运行时类Test06.class 作为锁对象 public synchronized static void sm2() { for (int i = 1; i <= 10; i++) { System.out.println(Thread.currentThread().getName() + " --> " + i); } } }
3.3.6 工作原理
synchronized底层:
JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的。 具体实现是在编译之后在同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令。 其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。 而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。
static class A{ public static void main(String[] args) { new A().fun1(); // this对象 A.fun2(); // A.class对象 } //修饰 普通方法 this public synchronized void fun1(){ // 同步代码 } //修饰 静态方法 public synchronized static void fun2(){ // 同步静态代码块 } }
注意: 对象如同锁,持有锁的线程可以在同步中执行 没持有锁的线程即使获取CPU的执行权,也进不去 同步的前提: 1.必须要有两个或者两个以上的线程 2.必须是多个线程使用同一个锁 优缺点: 好处:解决了多线程的安全问题 弊端:多个线程需要判断锁,较为消耗资源、抢锁的资源。 修饰方法注意点: synchronized 修饰方法使用锁是当前this锁。 synchronized 修饰静态方法使用锁是当前类的字节码文件
3.4 volative 关键字
3.4.1 volatile 的作用
volatile 关键字的作用是变量在多个线程之间可见。并且能够保证所修饰变量的有序性: 1、保证变量的可见性:当一个被 volatile 关键字修饰的变量被一个线程修改的时候,其他线程可以立刻得到修改之后的结果。当一个线程向被 volatile 关键字修饰的变量写入数据的时候,虚拟机会强制它被值刷新到主内存中。当一个线程用到被 volatile 关键字修饰的值的时候,虚拟机会强制要求它从主内存中读取。 2、屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,它只能保证程序执行的结果时正确的,但是无法保证程序的操作顺序与代码顺序一致。这在单线程中不会构成问题,但是在多线程中就会出现问题。非常经典的例子是在单例方法中同时对字段加入 volatile ,就是为了防止指令重排序。
3.4.2 volatile 非原子特性
volatile 关键字增加了实例变量在多个线程之间的可见性,但是不具备原子性。
/** * volatile 不是具备原子性 */ public class Test03 { public static void main(String[] args) { //在 main 线程中创建 10 个子线程 for (int i = 0; i < 10; i++) { new MyThread().start(); } } static class MyThread extends Thread { //volatile 关键仅仅是表示所有线程从主内存读取 count 变量的值 public static int count; /* 这段代码运行后不是线程安全的,想要线程安全, 需要使用 synchronized 进行同步, 如果使用 synchronized 同时,也就不需要 volatile 关键字了 */ // public static void addCount(){ // for (int i = 0; i < 1000; i++) { // //count++不是原子操作 // count++; // } // System.out.println(Thread.currentThread().getName() + " count=" + count); // } public synchronized static void addCount() { for (int i = 0; i < 1000; i++) { //count++不是原子操作 count++; } System.out.println(Thread.currentThread().getName() + " count=" + count); } @Override public void run() { addCount(); } } }
3.4.3 常用原子类进行自增自减操作
我们知道 i++操作不是原子操作,除了使用 Synchronized 进行同步外,也可以使用 AtomicInteger、AtomicLong 原子类进行实现。
3.4.4 数据原子操作
八种原子操作
搞清楚volatile底层实现原理,需要知道JMM的原理:数据原子操作。Java 内存模型对主内存与工作内存之间的具体交互协议定义了八种操作:
1. lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态 2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用 4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中 5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎 6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量 7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作 8. write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
3.5 volatile 与 synchronized 比较
1.volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比 synchronized 要好;volatile 只能修饰变量,而 synchronized 可以修饰方法、代码块,随着 JDK 新版本的发布,synchronized 的执行效率也有较大的提升,在开发中使用 sychronized 的比率还是很大的。
2.多线程访问 volatile 变量不会发生阻塞,而 synchronized 可能会阻塞。
3.volatile 能保证数据的可见性,但是不能保证原子性;而synchronized 可以保证原子性,也可以保证可见性。
4.关键字 volatile 解决的是变量在多个线程之间的可见性;synchronized 关键字解决多个线程之间访问公共资源的同步性。
3.6 CAS
CAS(Compare And Swap)是由硬件实现的。CAS 可以将 read- modify - write 这类的操作转换为原子操作。
i++自增操作包括三个子操作:
从主内存读取 i 变量值
对 i 的值加 1
再把加 1 之后 的值保存到主内存
CAS 原理:
在把数据更新到主内存前,再次读取主内存变量的值,如果现在变量的值与期望的值(操作起始时读取的值)一样就更新。
CAS的ABA问题
CAS 实现原子操作背后有一个假设:
共享变量的当前值与当前线程提供的期望值相同,就认为这个变量没有被其他线程修改过;实际上这种假设不一定总是成立,如有共享变量 count = 0
A 线程对 count 值修改为 10
B 线程对 count 值修改为 20
C 线程对 count 值修改为 0
当前线程看到 count 变量的值现在是 0,现在是否认为 count 变量的值没有被其他线程更新呢?这种结果是否能够接受?这就是 CAS 中的 ABA 问题,即共享变量经历了 A -> B -> A 的更新。
是否能够接收 ABA 问题跟实现的算法有关。如果想要规避 ABA 问题,可以为共享变量引入一个修订号(版本号),每次修改共享变量时,相应的修订号就会增加 1。
ABA 变量更新过程: [A,0] -> [B,1] -> [A,2],每次对共享变量的修改都会导致修订号的增加,通过修订号依然可以准确判断变量是否被其他线程修改过,AtomicStampedReference 类就是基于这种思想产生的。
本文来自博客园,作者:Lz_蚂蚱,转载请注明原文链接:https://www.cnblogs.com/leizia/p/15947997.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步