Java中保证线程安全的三板斧

前言

现在,如果要使用 Java 实现一段线程安全的代码,大致有 synchronized 、 java.util.concurrent 包等手段。虽然大家都会用,但却不一定真正清楚其在 JVM 层面上的实现原理,因此,笔者在查阅了一些资料后,希望把自己对此的一些见解分享给大家。

三板斧之一:互斥同步

  • 互斥同步:使用互斥的手段来保证同步操作。互斥是方法,同步是目的。
  • 在 Java 的世界里,最基本的互斥同步手段就是使用 synchronized 关键字。

synchronized 关键字

  1. synchronized 能实现同步的理论基础是:Java 中的每一个对象都可以作为锁。
  2. synchronized 关键字在不同的使用场景下,作为锁的对象有所不同,主要分为以下三种情况:
    • 对于同步代码块,锁就是声明 synchronized 同步块时指定的对象(synchronized 括号中配置的对象);
    • 对于普通对象方法,锁就是当前的实例对象;
    • 对于静态同步块,锁就是当前类的 Class 对象。
  3. 我们可以通过一段代码来进一步说明 synchronized 是如何实现互斥同步的。
  • 示例代码
public class SynchronizedTest {public void test() {synchronized (this) {try {System.out.println("SynchronizedTest.test() method start!");} catch (Exception e) {}}}
}
  • 对上述代码生成的字节码使用 Javap 进行反编译,结果如下:
Compiled from "SynchronizedTest.java"
public class com.xxx.JVMTest.SynchronizedTest {public com.xxx.JVMTest.SynchronizedTest();Code:0: aload_01: invokespecial #1                  // Method java/lang/Object."<init>":()V4: returnpublic void test();Code:0: aload_01: dup2: astore_13: monitorenter4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;7: ldc           #3                  // String SynchronizedTest.test() method start!9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V12: aload_113: monitorexit14: goto          2217: astore_218: aload_119: monitorexit20: aload_221: athrow22: returnException table:from    to  target type4    14    17   any17    20    17   any
}
  • 我们可以看到反编译的代码中,存在两个由 Javac 编译器加入的指令,分别是插入到同步代码块开始位置的 monitorenter 指令和插入到同步代码块结束位置以及异常处的 monitorexit 指令。
  • 根据《Java 虚拟机规范》可知,每个 Java 对象都有一个监视器锁(monitor)。在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经持有了该对象的锁,就把锁的计数器的值加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦锁计数器的值为零,锁随即被释放。如果其他线程已经占用了该对象的锁,则该线程进入阻塞状态,直到锁的计数器为零时,再重新尝试获取该对象的所有权。
  • 因此,本质上 JVM 就是通过进入 Monitor 对象(monitorenter)以及退出 Monitor 对象(monitorexit)来实现方法和代码块的同步操作。
  1. 通过对 monitorenter 指令和 monitorexit 指令的分析,我们可以推出 synchronized 的三条结论:
  • 被 synchronized 声明的同步代码块对同一线程而言是可重入的,所以同一线程重复进入同步块也不会出现被自己锁死的情况;
  • 被 synchronized 声明的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。因此无法实现对已经获得锁的线程强制释放锁的操作,以及对等待锁的线程实现中断等待或超时退出的机制。
  • 由于 Java 线程是映射到操作系统的原生内核线程之上的,如果要阻塞或者唤醒一条线程,则需要操作系统来帮忙完成,这不可避免地陷入用户态到核心态的转变之中,因此在一些经典的 Java 并发编程资料中,synchronized 被形象地称为重量级锁。但它相对于利用 java.util.concurrent 包中 Lock 接口实现的锁机制仍有一个先天的优势,就是 synchronized 的锁信息是被 JVM 记录在线程和对象的元数据中的,可以很轻易的知道当前哪些锁对象是被哪些特定的线程所持有,从而更容易进行锁优化。
  1. 在这里需要补充一点的就是,同步方法虽然也可以使用 monitorenter 指令和 monitorexit 指令实现同步操作,但实际上目前的实现中并没有采用这种方案
  • 我们可以具体分析下面的代码
public class SynchronizedTest {public synchronized void testTwo() {System.out.println("SynchronizedTest.testTwo() method start!");}
}
  • 对上述代码生成的字节码使用 Javap 进行反编译,结果如下:
  public synchronized void testTwo();descriptor: ()Vflags: ACC_PUBLIC, ACC_SYNCHRONIZEDCode:stack=2, locals=1, args_size=10: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;3: ldc           #6                  // String SynchronizedTest.testTwo() method start!5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V8: returnLineNumberTable:line 23: 0line 24: 8LocalVariableTable:Start  Length  Slot  Name   Signature0       9     0  this   Lcom/xxx/JVMTest/SynchronizedTest;
  • 从反编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成。相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。实质上 JVM 是根据该标示符来实现方法的同步的,当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 Monitor 锁,获取成功之后才去执行方法体,并在方法执行完后释放 Monitor 锁。同时,在方法执行期间,其他任何线程都无法再获得同一个 Monitor 锁对象。
  • 方法的同步和代码块的同步没有本质区别,只是其用一种隐式的方式来实现,无需通过字节码来完成。

三板斧之二:非阻塞同步

  • 根据上一小节我们可以知道,在进行互斥同步时,无论共享的数据是否真的存在竞争,它都会进行加锁操作,从而导致用户态与核心态的转换、维护锁计数器以及检查是否有等待锁的线程需要被唤醒等额外开销,因此互斥同步属于一种悲观的并发策略。
  • 那么是否存在一种乐观的并发策略呢?答案是有的,目前在 Java 中实现了一种基于冲突检测的加锁策略 ———— CAS 操作。
  • 通俗的说就是先不管是否存在竞争,先进行操作,一旦产生了冲突,再通过其他补偿手段进行修正。最常见的就是通过不断地重试,直到没有竞争为止。
  • 这种策略地好处在于全程是处于用户态中进行操作,从而避免了频繁地用户态与核心态之间的切换操作。
  1. 直到 JDK 5 ,在 java.util.concurrent.atomic 包中才提供了一些类支持原子级别的 CAS 操作,包括 AtomicBoolean、AtomicInteger、AtomicLong 等,而这些类的方法大多数又是调用的 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个保证原子操作的方法。
  • 以 java.util.concurrent.atomic.AtomicInteger 类的 getAndIncrement() 方法为例:
public class AtomicInteger extends Number implements java.io.Serializable {static {try {//获取 value 变量的偏移量, 赋值给 valueOffsetvalueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));} catch (Exception ex) { throw new Error(ex); }}/*** Atomically increments by one the current value.** @return the previous value*/public final int getAndIncrement() {return unsafe.getAndAddInt(this, valueOffset, 1);}...other methods...
}/*==========================================*/public final class Unsafe {public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {//通过对象和偏移量获取变量的值//由于 volatile 的修饰, 因此所有线程看到的 var5 都是一样的var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));return var5;}...other methods...
}
  • 我们可以看到 Unsafe 类的 getAndAddInt() 方法中存在一个 do while 循环,而循环条件中的 compareAndSwapInt() 方法会以原子的方式尝试修改 var5 的值。
  • 具体而言,该方法通过 obj 和 valueOffset 获取变量的值,如果这个值和 var5 不一样,说明其他线程已经先一步修改了 obj + valueOffset 地址处的值,此时 compareAndSwapInt() 返回 false,继续循环;如果这个值和 var5 一样,说明没有其他线程修改 obj + valueOffset 地址处的值,此时可以将 obj + valueOffset 地址处的值改为 var5 + var4 ,compareAndSwapInt() 返回 true,退出循环。由于 compareAndSwapInt() 方法是原子操作, 所以compareAndSwapInt() 修改 obj + valueOffset 地址处的值时不会被其他线程中断。
  1. 通过上面的例子我们可以发现,使用 CAS 来实现同步操作也引发了一些新的问题:
  • 如果自旋 CAS 长时间不成功,就会白白浪费本来就宝贵的 CPU 时间;
  • 理论上而言,CAS 也只能保证一个共享变量的原子操作,功能上并没有 synchronized 同步代码块丰富;
  • ABA问题:我们可以假设这样一种场景,如果一个值原来是A,变成了B,之后又变回了A,那么在使用 CAS 操作进行检查时会出现以为它的值没有发生变化,而实际上已经变化了的情况。不过实际上即使出现了 ABA 问题在大部分并发情况下也不会影响程序的并发正确性,如果证实确实存在影响,那么最好改用 synchronized 同步代码块来实现同步操作。

三板斧之三:无同步线程安全

  • 其实,同步与否与是否线程安全没有必然联系,同步只是实现线程安全的一种手段,如果存在有竞争的共享数据那么使用同步手段来保证线程安全也不失为一种好的方案,但如果本来就不存在竞争的可能,那它本身就有隐式的线程安全保证。
  1. 可重入代码(纯代码)

是一种允许多个进程同时访问的代码。程序在运行过程中可以被打断,并由开始处再次执行,并且在合理的范围内(多次重入,而不造成堆栈溢出等其他问题),程序可以在被打断处继续执行,且执行结果不受影响。(可重入代码 | 百度百科)

  1. 可重入代码拥有一些共同的特征:
  • 不依赖全局变量;
  • 不依赖存储在堆上的数据和公用的系统资源;
  • 使用到的状态量都由参数传入;
  • 不调用其他非可重入的方法; …
  1. 因此,如果一段代码中存在与其他代码的共享变量,只要能保证这些变量的可见范围只在同一个线程内,那么无需同步也能保证线程之间的数据安全性。
  2. 在 Java 中,使用了 java.lang.ThreadLocal 类来实现线程本地存储的功能,每个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K - v 键值对。由于每个线程的 ThreadLocal.threadLocalHashCode 的值都是独一无二的,因此所映射的值也只能该线程自己才能访问到,也就实现了线程安全。

总结

  1. 可以使用互斥同步(阻塞同步)的方式,实现共享变量的线程安全,典型例子包括:synchronized 等;
  2. 可以使用自旋 CAS 的方式,实现共享变量的线程安全,典型例子包括:sun.misc.Unsafe 类、java.util.concurrent.atomic 包中的 AtomicBoolean、AtomicInteger、AtomicLong 等;
  3. 如果可以保证共享变量的可见范围均在同一个线程之内,那么其本身就带有隐式的线程安全性,不需要再做其他显式的同步操作。

参考文献

  1. 方腾飞, 魏鹏, 程晓明.Java并发编程的艺术 [M]. 北京:机械工业出版社,2015:11-20.
  2. 周志明.深入理解Java虚拟机: JVM高级特性与最佳实践(3 版)[M]. 北京:机械工业出版社,2019:471-478.

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/555586.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

mysql oa数据库设计_OA项目1:环境搭建之数据库创建与环境添加

首注&#xff1a;本学习教程为传智播客汤阳光讲师所公布的免费OA项目视频我的文字版实践笔记&#xff0c;本人用此来加强巩固自己开发知识&#xff0c;如有网友转载&#xff0c;请注明。谢谢。一 指定数据库&#xff1a;Mysqldatabase&#xff1a;oa建库语句&#xff1a;crea…

java 线程安全的原因_java的多线程:java安全问题产生的原因与JMM的关系

一、多线程产生安全问题 1、Java内存模型 共享内存模型指的就是Java内存模型(简称JMM)&#xff0c;JMM决定一个线程对共享变量的写入时,能对另一个线程可见。 从抽象的角度来看&#xff0c;JMM定义了线程和主内存之间的抽象关系&#xff1a; 线程之间的共享变量存储在主内存…

python用pip安装wordcloud_如何在python3.7中使用pip安装wordcloud

我一直试图在windows10上pip安装python3.7的wordcloud&#xff0c;但我一直收到这个错误消息&#xff0c;我不太理解&#xff0c;也无法修复。在正在为wordcloud运行setup.py安装&#xff1a;已完成&#xff0c;状态为“error”Complete output from command "c:\program …

分布式系统全局唯一ID的几种实现方式

现如今可谓是微服务、分布式、IoT&#xff08;物联网&#xff09;横行的时代&#xff0c;作为一名开发者始终还是要保持一定的危机意识&#xff0c;特别是在日常的项目开发中&#xff0c;若是有机会接触到一些关于微服务、分布式下的应用场景&#xff0c;应当硬着头皮、排除万难…

git如何查看缓存区文件内容_详解Git工作区、暂存区、历史记录区以及git reset、git revert、git checkout等撤销命令的区别...

一、可以将git简单的分为三个区域 1、工作区(working directory) 2、暂缓区(stage index) 3、历史记录区(history) 如图&#xff1a;其中git add files 把当前工作目录中的文件放入暂存区域这其实做了两件事&#xff1a; 1、将本地文件的时间戳、长度&#xff0…

分布式全局唯一ID的实现

分布式全局唯一ID的实现 前言 上周末考完试&#xff0c;这周正好把工作整理整理&#xff0c;然后也把之前的一些素材&#xff0c;整理一番&#xff0c;也当自己再学习一番。 一方面正好最近看到几篇这方面的文章&#xff0c;另一方面也是正好工作上有所涉及&#xff0c;所以决…

mysql多个分类取n条_MySQL获取所有分类和每个分类的前N条记录

MySQL获取所有分类和每个分类的前N条记录。比如有文章表 test(Id,type,tiem)&#xff0c;现在要用SQL找出每种类型中时间最新的前N个数据组成的集合&#xff0c;一段不错的代码&#xff0c;留存备用。SELECT a1.* FROM test a1INNER JOIN (SELECT a.type,p.time FROM test aLEF…

Java接口学习(接口的使用、简单工厂、代理模式、接口和抽象类的区别)

前言引入 官方解释&#xff1a;Java接口是一系列方法的声明&#xff0c;是一些方法特征的集合&#xff0c;一个接口只有方法的特征没有方法的实现&#xff0c;因此这些方法可以在不同的地方被不同的类实现&#xff0c;而这些实现可以具有不同的行为&#xff08;功能&#xff0…

java 矩阵转置_图解利用Java实现数组转置

我们编写Java代码&#xff0c;如下图所示&#xff1a;package com.tina;public class demo {public static void main(String args[]) {int data[] new int[] { 1, 6, 3, 9, 5, 7, 2, 0, 4, 8 };PrintArray(data);}// 输出数组内容public static void PrintArray(int arr[]) {…

java中static、final、static final浅析

final final可以修饰类、属性、方法、局部变量、参数&#xff0c;不能修饰接口&#xff01;final修饰类&#xff1a;该类不能被继承&#xff08;解释了为什么不能修饰接口&#xff0c;不过接口里面的属性、方法等是可以用final修饰的&#xff09;&#xff1b;final修饰属性&am…

最短路径 floyd java_java实现Floyd算法求最短路径

关于无向图的最短路径问题&#xff1a;这个程序输出&#xff1a;最短路径矩阵例如:W[0][5]9 代表vo->v5的最短路径为9W:0 1 3 7 4 91 0 2 6 3 83 2 0 4 1 67 6 4 0 3 24 3 1 3 0 59 8 6 2 5 0package com.xh.Floyd;import java.util.ArrayList;public class Floyd_01 {publi…

SpringBoot 使用 log4j2

一、新建工程 选择一些基础依赖 填写工程名称和项目路径 二、工程配置 修改文件编码格式 设置Java Compiler 修改maven配置文件路径 三、pom.xml的web依赖中排除掉logging依赖&#xff0c;并且引入log4j2依赖 <dependency><groupId>org.springframework.…

springBoot 通过使用log4j2

1.排除 Spring-boot-starter 默认的日志配置 将原本的 spring-boot-starter 改为 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId><exclusions><exclusion><groupId>…

java finalize 何时被调用_finalize()方法什么时候被调用?析构函数(final

finalize()方法也叫收尾方法。一旦垃圾回收器准备好释放对象占用的存储空间&#xff0c;首先会去调用finalize()方法①进行一些必要的清理工作(对垃圾回收器不能处理的特殊情况进行处理)(例子在下边)②也有可能使该对象重新被引用&#xff0c;我习惯叫这种作用为复活。注意&…

编程和java是什么关系_C语言和Java编程有什么区别?

C语言和Java编程有什么区别&#xff1f;Java从根本上说是c之后的一种改进语言&#xff0c;纯面向对象的一种编程语言(当然比起Ruby还是差一点)&#xff0c;有了C语言的基础固然对学习Java有帮助&#xff0c;因为在某种程度上Java和C语言是比较接近的。但是如果没有学习过C语言也…

SpringBoot默认日志logback配置解析

SpringBoot默认日志logback配置解析 前言 今天来介绍下Spring Boot如何配置日志logback,我刚学习的时候&#xff0c;是带着下面几个问题来查资料的&#xff0c;你呢 如何引入日志&#xff1f;日志输出格式以及输出方式如何配置&#xff1f;代码中如何使用&#xff1f; 正文…

java lang报错_java.lang.UnsupportedClassVersionError:JDK版本不一致报错

08-15 14:13:29 ERROR doPost(jcm.framework.rmi.RMIServlet:155) -SchedulerService.forceRunJobFlow error.未指定错误&#xff0c;请查看详细信息at jcm.framework.rmi.ClientService.execute(ClientService.java:129)at ...(...)at jcm.flowengine.impl.JobFlowEngine.runJ…

SpringBoot 之Spring Boot Starter依赖包及作用

spring-boot-starter 这是Spring Boot的核心启动器&#xff0c;包含了自动配置、日志和YAML。 spring-boot-starter-amqp 通过spring-rabbit来支持AMQP协议&#xff08;Advanced Message Queuing Protocol. 。 spring-boot-starter-aop 支持面向方面的编程即AOP&#xff0…

java中怎么判断相等_Java中判断相等 (== 与 .equals())

1.Java中有两种判断相等的方法&#xff1a;1.1首先是运算符对于基本类型而言&#xff0c;运算符比较的是值是否相等(本质也是比较的地址&#xff0c;因为常量在常量池中的地址不可改变)int a 3;int b 3;System.out.println(ab);//结果为true对于引用类型而言&#xff0c;运算…

SpringBoot查看和修改依赖的版本

springBoot依赖管理&#xff1a; 1、引入父项目的作用是实现对所有依赖的管理。 <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.3.4.RELEASE</version> &l…