线程安全分类及虚拟机锁优化

1. 线程安全问题

    线程安全问题是多线程之间访问共享数据时的数据安全问题;

线程安全按数据操作安全等级可以分为 5 大类:不可变绝对线程安全相对线程安全线程兼容线程对立
线程安全的同步措施:互斥同步非阻塞同步无同步措施
线程安全的锁优化:适应性自旋锁消除锁粗化轻量级锁偏向锁

2. 线程安全-线程安全的类别

2.1 不可变

    比如 final 修饰的对象、常量,是不会改变的,在多个线程之中永远是一致的状态。不存在线程安全问题。

    public final int value = 1;

2.2 绝对线程安全

    是指对于一个类,不管运行时环境如何,调用者都不需要给出额外的同步措施。

    绝对线程安全的成本最大,不惜效率保证所有的操作是安全的。即便是 java.util.Vector 是一个线程安全的容器,但类 Vector 仍然不是绝对的线程安全。存在并发环境下,操作同一个对象时出现不正确的操作。

2.3 相对线程安全

    是指对这个对象单独的操作是线程安全的,调用端不再需要额外的同步措施;

    例如类 Vector 的所有方法都有关键 synchronized 修饰
    因此,相对线程安全的对象,在一些特定顺序的连续调用,需要调用端做额外的同步措施,来保证调用的正确性。* 在 Java 中大多数线程安全的类归类于相对线程安全;如 Vector、HashTable

示例

import java.util.Vector;

/**
 * @description: 绝对线程安全
 * 绝对线程安全,是指对于某个类,在调用端不再需要额外的同步措施
 * 如下例子如果不加额外的同步措施,运行后会抛出异常 
 * Exception in thread "Thread-34340" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 9
 * 	at java.util.Vector.remove(Vector.java:831)
 *
 * 	尽管 Vector 的方法都是线程安全的方法,多线程环境下,不在方法调用端做额外的同步措施,存在某个线程恰好删除了一个元素,导致序号 i 不可用,就会出现越界异常。
 * 	需要 synchronized (vector) {} 在调用端额外做同步措施
 **/
public class ThreadSafeAbsolute {

    private static Vector<Integer> vector = new Vector<Integer>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }

            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                	// 额外的同步措施
                    synchronized (vector) {
                        for (int i = 0; i < vector.size(); i++) {
                            vector.remove(i);
                        }
                    }
                }
            });

            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                	// 额外的同步措施
                    synchronized (vector) {
                        for (int i = 0; i < vector.size(); i++) {
                            System.out.println(vector.get(i));
                        }
                    }
                }
            });

            removeThread.start();
            printThread.start();

            // 不要开启过多线程,会导致线程假死
            while (Thread.activeCount() > 20) {}
        }
    }
}

2.4 线程兼容

    是指对象本身不是线程安全的,但通过在调用端额外的做同步措施,能保证对象在并发环境下操作是线程安全的。这样的类或对象是属于线程兼容的。

     Java API 中的大部分类是线程兼容的;如 ArrayList、HashMap

2.5 线程对立

    是指无论作什么同步措施都不能保证对象在并发环境下是线程安全的。

    例:线程类 Thread 的 suspend() 和 resume() 方法,两个线程同时持有一个线程对象,一个尝试中断线程,一个尝试唤起线程,并发进行下无论调用时是否做了同步措施,该线程都存在死锁风险。
    如果中断的线程就是即将要被唤醒的线程,就会发生死锁,以上两个方法被废弃也是这个原因。

3. 同步措施-线程安全的实现方法

3.1 互斥同步

    多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。

    临界区、互斥量、信号量都是主要的互斥实现方式;

  • 临界区
    当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。在临界区中使用适当的同步就可以避免竞态条件。
  • 互斥量
    一种锁机制,多个线程竞争临界区的资源,一个线程已获取临界区资源,其他线程就无法获取临界区资源,需等待该线程释放掉临界区资源。
  • 信号量
    一个线程同步结构,用于在线程间传递信号,以避免出现信号丢失。
    一种信号机制,某个任务完成后可通过信号量通知到其他的任务执行后续的动作。

synchronized 的互斥同步原理

    Java 最基本的互斥实现就是关键字 synchronized;
    被该关键字修饰的同步块,前后会生成两个字节码指令 monitorenter 和 monitorexit;
    在执行 monitorenter 指令前首先尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有对象的锁,则把锁的计数器加一;相应的在执行 monitorexit 指令时,则将锁定额计数器减一;当锁的计数器为零时,则释放锁;
    如果获取锁失败,则当前线程就要阻塞等待,直到对象锁被另一个线程释放。

ReentrantLock 的互斥同步

    相比 synchronized 增加了一些高级功能,主要有等待可中断、可实现公平锁、锁可绑定多个条件;
    通过方法 lock() 和 unlock() 配合 try/finally 使用

  • 等待可中断
    是指当前持有锁的线程长期不释放锁时,正在等待的线程可以选择放弃等待,去处理其他的操作。
  • 公平锁
    公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间来依次获得锁;
    非公平锁,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
    synchronized 是非公平锁,ReentrantLock 默认也是非公平锁,可以通过构造方法要求使用公平锁;
  • 绑定多个条件
    是指 ReentrantLock 对象可以同时绑定多个 Condition 对象;需要和多个条件关联时,则需要多次调用 newCondition() 方法即可。
    在 synchronized 下锁的对象 wait() 和 notify() notifyAll() 方法可以实现一个隐含的条件;如要支持多个条件,则需要额外添加一个锁;

    JDK 1.6 之后,synchronized 和 ReentrantLock 性能差不多,推荐使用 synchronized

状态转换耗时操作

    Java 线程是映射到操作系统的原生线程上,如果要阻塞或唤醒一个线程,都需要操作系统来完成,这就需要从用户态转换到核心态中,状态转换需要耗费很多处理器时间。

3.2 非阻塞同步

    是指在并发环境下,先进行操作,如果没有其他线程争用共享数据,操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施,比如重试机制。

    这种策略属于乐观并发策略,大多乐观并发策略的实现不需要把线程挂起。
    互斥同步相对的是一种悲观并发策略,认为只要不做正确的同步措施,操作就是不安全的,无论共享数据是否真的存在争用,都要进行加锁。

冲突检测指令

  • 测试并设置 Test-and-Set
  • 获取并增加 Fetch-and-Increment
  • 交换 Swap
  • 比较并交换 Compare-and-Swap (CAS)
  • 加载链接/条件存储 Load-Linker/Store-Conditional (LL/SC)
3.2.1 CAS 指令

    Java 中的比较并交换实现;
    比较并交换 CAS 指令需要三个操作数:内存地址、旧值、新值;
    比较并交换是指,当且仅当内存地址的值符合旧值时,处理器用新值更新内存地址的值;否则不更新内存地址的值。上述操作是原子操作。

伪代码

    if (address -> value == oldValue) {
        address -> value = newValue;
    }

    Java 的交换并替换操作通过 sun.misc.Unsafe 实现,一般的需要通过反射或者 Java API 来间接调用

    public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
    
    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }
    
    /**
     * getAndSetInt 内部是一个无限循环,
     * 通过 CAS 指令不断尝试将一个新值 var4 赋给当前地址,如果失败了,则不断尝试,直到比对成功,再将新值替换掉旧值
     */
    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;
    }

    如 java.util.concurrent.atomic.AtomicInteger 的部分方法使用了 Unsafe 的 CAS 操作。

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    /**
     * Atomically sets the value to the given updated value
     * if the current value {@code ==} the expected value.
     *
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful. False return indicates that
     * the actual value was not equal to the expected value.
     */
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    
    /**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
}
3.2.2 ABA 问题
  • ABA 问题描述
        在 CAS 语义上存在一个漏洞,如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它的值仍然是 A 值,CAS 操作会认为它从来没改变过。实质上这段期间如果他的值被改为 B 值,然后又被改为 A 值,就不会被 CAS 检测到这种变化。
  • ABA 问题处理
        JUC 包提供了一个带有原子标记的类 AtomicStampedReference,可以通过控制变量值的版本来保证 CAS 的正确性。或者通过互斥同步措施来处理。

3.3 无同步方案

    对于不涉及到共享数据的方法,自然是不存在线程安全的问题的。无需额外的同步措施去保证操作的安全性。

    可重入代码、线程本地存储

  • 可重入代码
        是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,在控制权返回时,原来的程序不会出现任何错误。
    可重入代码不依赖存储在 Java 堆上的数据或公共的系统资源,用到的状态量都是由参数传入,不调用非可重入的方法;
  • 线程本地存储
        如果一段代码中所需的数据必须与其他代码共享,如果共享数据的代码可以限制在一个线程中执行,这样无需额外的同步措施也不会出现数据争用问题。

    Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字声明它为“易变 的”;如果一个变量要被某个线程独享,Java中就没有类似C++中__declspec(thread)[3]这样 的关键字,不过还是可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线 程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就 是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的 threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

4. 锁优化-虚拟机的锁优化技术

    锁优化技术常见的有适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁

    自旋锁在存在竞争的情况下,让当前线程自旋(忙循环)等待后,尝试获取对象的锁。
    锁消除是对于不存在共享资源的代码块去除同步措施;
    锁粗化是对于频繁加锁解锁操作适当扩大加锁范围;
    轻量级锁在无竞争的情况下,使用 CAS 操作做同步;
    偏向锁在无竞争的情况下,不再任何同步操作;

4.1 自旋锁

    是指当前线程获取锁时,锁已被其他线程持有,当前线程不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,需要让线程执行一个忙循环,这个忙循环称为自旋。这种技术称为自旋锁。

    自旋的次数默认为 10 次;可以使用参数 -XX:PreBlockSpin 来更改。
    自旋锁的弊端,占用处理器资源,如果自旋次数较多占用时间过长,还没有等到持有锁的线程释放锁,就比较浪费处理器资源。

4.2 锁消除

    是指处理器在即时编译器运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。

    锁消除的主要依据来源于逃逸分析的数据支持。如果同步代码中,堆上的所有数据都不会逃逸出去,就不会被其他线程访问到,同步加锁措施就无需进行。

4.3 锁粗化

    频繁的加锁操作存在一定的性能消耗;如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至出现在循环体内的加锁操作,则虚拟机会扩大加锁的范围,粗化锁的作用范围,避免这种不必要的性能消耗。

    原则上是建议锁足够细化,保证同步的操作数量足够小,只在共享数据的实际作用域才进行同步。

4.4 轻量级锁

    轻量级锁是相对的概念,传统的锁都是重量级锁,轻量级锁的引用是为了减少重量级锁使用操作系统的互斥量的性能消耗。

4.5 偏向锁

    消除数据在无竞争情况下的同步原语。

    如果说轻量级锁是在无竞争的情况下使用 CAS 操作去除同步使用的互斥量;
    偏向锁就是在无竞争的情况下把整个同步都消除掉
    使用参数 -XX:-UseBiasedLocking 来禁止偏向锁优化

4.6 扩展

  • HotSpot 虚拟机的对象内存布局

    对象头分为两部分信息
    一部分存储对象自身的运行时数据,如哈希码、GC 分代年龄等,这部分数据被称为 Mark Word;
    另一部分存储指向方法区对象类型数据的指针,如果是数组对象,还有一个额外的部分存储数组的长度。

    Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储更多的信息。
例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32bit 空间中,25bit 存储对象的哈希码,4bit 存储对象的分代年龄,2bit 存储锁标志位,1bit 固定为0.
锁标志位

存储内容标志位状态
对象哈希码、对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10重量级锁定(膨胀)
空,不需要记录信息11GC标记
偏向线程ID、偏向时间戳、对象分代年龄01
  • 轻量级锁的加锁过程
        在代码进入同步块的时候,如果同步对象没有被锁定,锁标志位为 01,虚拟机首先在当前线程的战阵中建立一个名为所记录的空间,用于存储锁对象目前的 Mark Word 的拷贝,这个拷贝被称为 Displaced Mark Word。
    然后虚拟机将使用 CAS 操尝试将对象的 Mark Word 更新为指向锁记录的指针,即指向 Displaced Mark Word;
        如果这个更新操作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位转变为 00,即表示当前对象处理轻量级锁锁定状态。
        如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果指向则说明当前线程已经拥有了该对象的锁,就可以直接进入同步块继续执行;否则说明该对象已经被其他线程抢占。如果有两条以上的线程争用同一个锁,轻量级锁要膨胀为重量级锁,锁标志位变为 10,Mark Word 存储的就是指向重量级锁(互斥量)的指针,后面的线程要进入阻塞状态

    如果没有锁竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销;
    如果存在锁竞争,还是用轻量级锁,除了互斥量的开销,还要做 CAS 操作,此时轻量级锁比重量级锁还要慢。

  • 偏向锁技术

    如上表,对象头的 MarkWord 在偏向锁和未锁定状态下的标志位都是 01,区别是存储内容不同。未锁定状态下,Mark Word 存储对象哈希码、对象分代年龄;可偏向状态下,存储的是偏向线程 ID、偏向时间戳、对象分代年龄;
虚拟机通过设置参数 -XX:+UseBiasedLocking 可以启动偏向锁;当锁对象第一次被线程获取的时候,虚拟机将会把对象头的 Mark Word 的标志位设置为 01,此时为偏向模式,同时使用 CAS 操作把这个锁的线程 ID 记录在 Mark Word 中;如果 CAS 操作成功持有偏向锁的线程每次进入这个锁的同步代码块时,虚拟机都可以不再进行额外的同步操作。
    当存在另外一个线程尝试获取这个锁时,偏向模式结束;根据锁对象当前被锁定状态,撤销偏向恢复到未锁定或者轻量级锁的状态。然后按照轻量级锁加锁过程进行处理。

Powered By niaonao
以上内容参照深入理解Java虚拟机一书

相关推荐
©️2020 CSDN 皮肤主题: 博客之星2020 设计师:CY__ 返回首页