写点什么

Java 线程安全 -JVM 角度解析

发布于: 20 小时前

文章已同步至 GitHub 开源项目: JVM底层原理解析

线程安全

​ 当多个线程同时访问一个对象,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要考虑额外的同步,或者在调用方法时进行一些其他的协作,调用这个对象的行为都可以获得正确的结果。那么就称这个对象是线程安全的。


​ 这个定义是严谨并且有可操作性的,他要求线程安全的代码都必须具备一个共同的特性。代码本身封装了所有必要的正确性保障手段(如互斥同步等)。令调用者无需关心多线程下的调用问题。更无需自己实现任何措施来保证安全。

Java 中的线程安全

​ 在 Java 语言中,从 JVM 底层来看的话,线程安全并不是一个非黑即白的二元排他选项,按照安全程度来划分,我们可以将 Java 中各种操作共享的数据分为五类: 不可变绝对线程安全相对线程安全线程兼容线程对立


​ 接下来,我们一一介绍。


  • 不可变


  • ​ 在 Java 中,不可变的对象一定是安全的。比如用 final 修饰的变量。只要一个不可见的对象被创建出来,其外部的可见状态就不会改变,永远不会看到它在多个线程中处于不一致的状态。在 Java 中,不可变带来的线程安全是最直接的,也是最纯粹的。

  • ​ 基本数据类型: 在定义的时候用 final 修饰即可。

  • ​ 引用数据类型:由于目前为止 Java 中还没有提供对应的支持,需要我们自己封装,将共享数据封装为一个不可变的对象,具体来说,我们可以把对象中的属性封装为 final 类型。这样在构造方法结束之后,他就是一个不可比变的值。

  • ​ 比如 String,Integer,Number,Long,Double 等基本数据类型的包装类,都是将 value 部分修饰为 final。

  • ​ String 的源码


  private final char value[];
复制代码


Integer 的源码


  private final int value;
复制代码


Double 的源码


  private final double value;
复制代码


  • 绝对线程安全


  • ​ 绝对线程安全能够完全满足线程安全的定义,但是在 Java 中标注自己是线程安全的类,并不一定是绝对线程安全的类。

  • ​ 比如 Vector 类,众所周知,它是一个线程安全类,常用的方法都被synchronized修饰。但是,它并不是绝对的线程安全,如果要做到绝对线程安全,必须在内部维护一组一致性的快照访问。每次对元素进行改动都要产生新的快照。但是付出的时间和空间成本是巨大的。

  • 相对线程安全


  • ​ 相对线程安全就是我们通常意义上讲的线程安全,他需要保证对这个对象的单次操作是安全的。在 Java 中,大部分的声明为线程安全的类都是这个级别。比如 Vector,HashTable,Collections 中的 synchronizedCollection()方法包装的集合等。

  • 线程兼容


  • ​ 线程兼容是指对象本身是非线程安全的,但是可以通过在调用端正确的使用同步手段(加锁)来保证在并发下是安全的。Java 中大部分的类都在此级别。比如 ArrayList,HashMap 等。

  • 线程对立


  • ​ 线程对立是指不管调用端如何进行同步加锁,都无法保证并发下的线程安全。在 Java 中这种类是很少的,我们要避免使用。比如 System.setIn(),System.setOut()等。

线程安全的实现方案

在 Java 中,实现线程安全,主要有三种方案, 互斥同步非阻塞同步无同步方案

互斥同步(悲观锁)

synchronized的实现

​ 此关键字经过 javac 编译之后,会生成两条字节码指令.monitorentermonitorexit


比如以下代码


public synchronized void dosomething(){        synchronized (SynchronizedTest.class){            System.out.println("do something");        }    }
复制代码


​ 反编译之后


0 ldc #2 <cn/shaoxiongdu/chapter6/SynchronizedTest> 2 dup 3 astore_1 4 monitorenter 5 aload_1 6 monitorexit 7 goto 15 (+8)10 astore_211 aload_112 monitorexit13 aload_214 athrow15 return
复制代码


​ 可以看到,在偏移址为 4 的地方,有一条字节码指令monitorenter,表示synchronized开始的地方,也就是表示开启同步的位置,在偏移址为 12 的地方,有一条monitorexit表示同步结束的地方。


​ 这两个指令都需要一个引用类型的参数来指明需要锁住的对象。如果代码中指定了,则使用指定的对象锁,如果出现在方法声明位置,那么虚拟机会判断,如果是实例方法则锁实例对象,如果是静态方法则锁类对象。


​ 在执行monitorenter时,首先虚拟机会尝试获取对象锁


  • 如果获取到对象锁,或者当前线程已经有了此对象锁

  • 则将对象锁中对象头位置的锁计数器+1,

  • 在执行到monitorexit时,会将其-1。一旦当前锁对象的锁计数器为 0,则当前线程就会释放对象的对象锁。

  • 如果获取不到,则当前线程进入阻塞状态。直到对象锁的值变为 0。也就是持有对象锁的线程释放该锁。


特征:


  • 可重入的,同一条线程进入同步块多次也不会被锁死。

  • 在同步块中执行的线程会无条件的阻塞其他线程的进入。这意味着无法像处理数据库那样强制让已获取锁的线程释放锁,也无法让正在的等待锁的进程退出。


从执行的成本来看,synchronized是一个重量级的操作。主流的 Java 虚拟机实现中,Java 的线程是映射到操作系统的内核线程中的,如果要唤醒或者阻塞一个线程,需要从用户态切换到内核态。这种转化是很耗时的。所以synchronized是一个重量级的操作。在有必要的情况下,再去使用其。

lock的实现

​ 在 JDK1.5 之后,Java 类库中新提供了 java.util.concurrent 包,其中的 locks.Lock 接口便成为 Java 另外一种互斥同步的手段。


​ 该接口的定义如下


public interface Lock {
//获取锁。如果锁已被其他线程获取,则进行等待。 void lock();
//如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。 void lockInterruptibly() throws InterruptedException;
//尝试获取锁,如果获取成功,则返回true 否则返回false 立即返回 不会和lock一样等待 boolean tryLock();
//拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。 boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();}
复制代码


​ 使用 Lock 接口的实现类,用户可以以非块结构来实现互斥同步,从而摆脱了语言的束缚,改为在类库层面去实现同步,这也为日后扩展出不同的调度算法,不同的特性,不同性能的各种锁提供了空间。


​ 重入锁(ReentrantLock)是 Lock 接口中最常见的一种实现方式。故名思意,他和synchronized一样是可以重入的。写法如下


public static void main(String[] args) {
Lock lock = new ReentrantLock(); lock.lock(); try{ //处理任务 }catch(Exception ex){
}finally{ lock.unlock(); //释放锁 不在finally处释放可能会发生死锁 } }
复制代码


相比synchronizedReentrantLock增加了如下的功能。


  • 等待可中断

  • ​ 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待。对于处理执行时间比较长的同步块很有帮助

  • 公平锁

  • ​ 当多个线程在等待同一个锁的时候,必须按照申请锁的顺序来依次获得锁。syn 是非公平的,reentrantLock默认也是非公平的,需要在构造函数中传入 true 指定使用公平锁。(使用公平锁会导致性能急剧下降)

  • 锁绑定多个条件

  • ​ 一个ReentrantLock对象可以同时绑定多个 Condition 对象。只需要多次调用 newCondition 方法即可。


这种互斥同步的放方案主要问题是在线程阻塞和唤醒的时候会带来性能开销问题。从解决问题的方式上看,互斥同步(阻塞同步)属于一种悲观的并发策略,认为只要是别的线程过来,就一定会修改数据。无论是否真的会修改,他都会进行加锁(此处讨论的是概念模型,实际虚拟机会优化一些不必要的加锁)。这会导致用户态和内核态频繁切换,并且需要维护锁的计数器。比较繁琐。

非阻塞同步(乐观锁)

基于冲突检测的乐观并发策略。


​ 通俗的说,就是不管风险,先进行操作。如果数据没有被修改,则修改成功。如果数据被修改,则不断重试。直到出现没有竞争的共享数据为止。


​ 此种方案需要硬件的发展,因为进行检测是否修改最终写入这两个操作必须保证原子性。如果这里用前边的互斥同步来解决,就没有什么意义了,所以需要硬件层面的支持。确保在语义上看起来有多个操作的行为只需要一条处理器指令就可以完成。常见的这种指令有


  • 测试并设置 TestAndSet

  • 获取并增加 FetchAndIncrement

  • 交换 Swap

  • 比较和交换: CompareAndSwap

  • 在 Java 中完成乐观锁用的是比较和交换CAS 指令。

  • CAS 指令需要有三个操作数,一个是旧的预期值 A,一个是内存位置 V,还有一个新值 B。

  • ​ 当旧的预期值与内存中真正的值相同的时候,就将旧值替换为新值。否则就不更新。

  • 在 JDK1.5 之后,Java 类库中才开始使用 CAS 操作,该操作由 sun.misc.Unsafe类中的方法包装提供。虚拟机会对这些方法进行特殊处理,保证编译之后是一条平台相关的处理器 CAS 指令。

  • 比如AtomicInteger就是包装了 CAS 指令之后的线程安全类,他的方法都设置在一个死循环中,不断尝试将一个新值赋给内存位置的值,如果失败,说明被其他线程改了,于是再次循环进行下一次操作,直到修改成功位置。

  • 尽管 CAS 看起来很美好,但是它存在一个逻辑漏洞,当别的线程将值从 A 改为 B,然后又改回 A 的时候,当前线程是不会发现的。这个漏洞叫做 CAS 的ABA问题,JUC 为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference。它通过控制变量值的版本来解决。


文章已同步至 GitHub 开源项目: JVM底层原理解析

发布于: 20 小时前阅读数: 23
用户头像

一直向上爬的蜗牛🐌 2021.07.27 加入

一直向上爬的蜗牛🐌

评论 (1 条评论)

发布
用户头像
欢迎大佬指点!
20 小时前
回复
没有更多了
Java线程安全-JVM角度解析