Java并发编程【线程中安全与锁】

2019年3月13日 作者 jacky

什么是线程安全

当多个线程访问某个类时,不管运行时环境采用何种的调度方式或者这些进程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类就是线程安全的。

线程安全主要体现在一下三个方面:

  • 原子性:提供了互斥访问,同一时刻只能有一个线程对它进行操作
  • 可见性:一个线程对主内存的修改可以及时的被其他线程观察到
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序的存在,该观察结果一般是杂乱无章

如何解决线程安全问题?

基本上所有的并发模式在解决线程安全问题时,都采用“序列化访问临界资源”的方案,即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。

  通常来说,是在访问临界资源的代码前面加上一个锁,当访问完临界资源后释放锁,让其他线程继续访问。

  在Java中,提供了两种方式来实现同步互斥访问:synchronized和Lock。

synchronized 的原理和使用

什么是synchronized?synchronized就是java的内置锁

修饰范围

  • 修饰方法,当synchronized修饰普通方法的时候,内置锁就是当前对象的实例
  • 修饰静态方法,当synchronized修饰静态方法的时候,内置锁就是当前Class字节码对象,也就是xx.Class
  • 修饰代码块,当synchronized修饰代码块的时候,取决于Object对象了

synchronized底层是使用同步监视器monitorenter来实现的,通过以下代码我们做一个说明

public boolean isBiggerThanZero(){
    synchronized (Sequence.class){
        if(value > 0){
            return true;
        }else{
            return false;
        }
    }
}

编译成字节码后

public boolean isBiggerThanZero();
    descriptor: ()Z
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #3                  // class Sequence
         2: dup
         3: astore_1
         4: monitorenter  //
         5: aload_0
         6: getfield      #2                  // Field value:I
         9: ifle          16
        12: iconst_1
        13: aload_1
        14: monitorexit  #
        15: ireturn
        16: iconst_0
        17: aload_1
        18: monitorexit
        19: ireturn
        20: astore_2
        21: aload_1
        22: monitorexit
        23: aload_2
        24: athrow
      Exception table:
         from    to  target type
             5    15    20   any
            16    19    20   any
            20    23    20   any
      LineNumberTable:
        line 10: 0
        line 11: 5
        line 12: 12
        line 14: 16
        line 16: 20
      StackMapTable: number_of_entries = 2
        frame_type = 252 /* append */
          offset_delta = 16
          locals = [ class java/lang/Object ]
        frame_type = 67 /* same_locals_1_stack_item */
          stack = [ class java/lang/Throwable ]

经过编译后的指令可以看出,synchronized关键字会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象的锁,如果获得了锁,把锁的计数器加1,相应地,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁便被释放了。由于synchronized同步块对同一个线程是可重入的,因此一个线程可以多次获得同一个对象的互斥锁,同样,要释放相应次数的该互斥锁,才能最终释放掉该锁

任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

需要注意一点的是:对于synchronized方法或者synchronized代码块,当出现异常时,JVM会自动释放当前线程占用的锁,因此不会由于异常导致出现死锁现象。

既然任何对象都可以作为锁,那么问题来了?锁的信息存在哪里呢?

其实是存在Java对象头中

  • Mark Word
  • Class MetaData Address
  • Array Length

Java对象头里的Mark Word里默认的存放的对象的Hashcode,分代年龄和锁标记位。

锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。通过-XX:-UseBiasedLocking来禁用偏向锁。对象的MarkWord变化为下图

偏向锁

每次获取锁和释放锁会浪费资源,很多情况下,竞争锁不是由多个线程,而是由一个线程在使用。为了让线程获得锁的代价更低而引入了偏向锁,偏向锁则是在只有一个线程执行同步块时进一步提高性能。

偏向锁获取

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。

  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。

  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。

  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

  5. 执行同步代码。

偏向锁释放

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

关闭偏向锁

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态

轻量级锁
  • 自旋锁
  • 多个线程可以同时访问

加锁

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。

  2. 拷贝对象头中的Mark Word复制到锁记录中。

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。

  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。

  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

轻量级锁CAS操作之前堆栈与对象的状态:

轻量级锁CAS操作之后堆栈与对象的状态:

解锁

  1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。

  2. 如果替换成功,整个同步过程就完成了。

  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

重量级锁

就是上面的synchronized

使用例子:用锁实现单例模式

//饿汉是没有并发问题,但是会浪费资源
//懒汉会有线程问题,但是资源占有少
public class Singleton {

    public Singleton() {
    }

    //private static Singleton instance = new Singleton();
    private static volatile Singleton instance =  null;

    /**
     * 双层检查枷锁
     *
     * @return
     */
    public static Singleton getInstance()  {
        if(instance == null){
            synchronized (Singleton.class){
                //非原子操作
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if(instance == null){
                    instance = new Singleton();   //还有一个安全问题,就是指令重排序,也就是不影响代码执行,将字节码执行的顺序提前或滞后
                    // ,使用volatile可以解决这个问题。
                    //1,申请一块内存空间
                    //2,在这块内存空间实例化
                    //3,instance的引用地址指向这块空间地址
                }
            }
        }
        return instance;
    }

}

以上锁的比较

锁的分类

  • 公平锁/非公平锁
  • 可重入锁
  • 独享锁/共享锁
  • 互斥锁/读写锁
  • 乐观锁/悲观锁
  • 分段锁
  • 偏向锁/轻量级锁/重量级锁
  • 自旋锁
公平锁/非公平锁
  • 公平锁是指多个线程按照申请锁的顺序来获取锁。
  • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的示例。

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。

对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

synchronized void method1() throws Exception{
    Thread.sleep(1000);
    //锁重入
    Method2();
}

synchronized void method2() throws Exception{
    Thread.sleep(1000);
}

上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,method2可能不会被当前线程执行,
可能造成死锁。

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。
共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
对于Synchronized而言,当然是独享锁。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
互斥锁在Java中的具体实现就是ReentrantLock
读写锁在Java中的具体实现就是ReadWriteLock

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
悲观锁在Java中的使用,就是利用各种锁。
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

Lock接口

  • synchronized
  • volatile
  • AtomicInteger…

Lock接口

在jdk1.5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,Lock接口提供了与synchronized关键字类似的同步功能,但需要在使用时手动获取锁和释放锁。虽然Lock接口没有synchronized关键字自动获取和释放锁那么便捷,但Lock接口却具有了锁的可操作性,可中断获取以及超时获取锁等多种非常实用的同步特性

使用lock锁的好处

  • Lock需要显示地获取和释放锁,繁琐能让代码更灵活
  • Synchronized不需要显示地获取和释放锁,简单

Lock接口相对于synchronzied来说增加强大:

  • 使用Lock可以方便的实现公平性
  • 非阻塞的获取锁
  • 能被中断的获取锁
  • 超时获取锁