2.6.3 Java线程安全-JVM角度解析

Java线程安全-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_2
11 aload_1
12 monitorexit
13 aload_2
14 athrow
15 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。它通过控制变量值的版本来解决。

------本页内容已结束,喜欢请分享------

感谢您的来访,获取更多精彩文章请收藏本站。

© 版权声明
THE END
喜欢就支持一下吧
点赞8 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片