多线程安全性和Java中的锁

2019-02-21  本文已影响89人  zhong0316

Java是天生的并发语言。多线程在带来更高效率的同时,又带来了数据安全性问题。一般我们将多线程的数据安全性问题分为三种:原子性、可见性和有序性。原子性是指我们的一系列操作要么全部都做,要么全部不做。可见性是指当一个线程修改了一个共享变量后,这个修改能够及时地被另一个线程看到。有序性是指在java为了性能优化,会对指令进行重排序,在本线程中我们的前后操作看起来是有序的,但是如果在另一个线程中观察,我们的操作是无序的。
为了解决多线程的数据安全性问题,java中引入了锁,锁是为了防止在多线程同时读写一个共享内存时出现的并发数据安全性问题。Java中的锁大体分为两类:"synchronized"关键字锁和"JUC"(java.util.concurrent包)中的locks包和atomic中提供的锁。

原子性,可见性和有序性

原子性

原子性是指我们的一系列操作是一个整体,要么全部做,要么全部不做,不能不分做,否则就会产生数据安全性问题。请看一个例子:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class AtomicityViolation {

    static long counter = 0L;
    static ExecutorService executorService = Executors.newFixedThreadPool(10);

    public static void main(String[] args) {
        violateAtomicity();
    }

    static void violateAtomicity() {
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        counter++;
                    }
                    latch.countDown();
                }
            });
        }
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter);
        executorService.shutdown();
    }
}

在上面的例子中,我们开启10个线程,每个线程负责对一个counter计数器累计10000次,如果没有安全性问题,我们期望得到的结果是100000,可是事实却并不如此,并且每次运行的结果都不一样,但是总是小于等于100000。为什么会这样呢?原因就在于counter++操作并不是一个原子操作。java内存模型规定了6种原子操作:read、load、assign、use、store和write。
如果我们要保证counter++是一个原子操作必须要对这个操作加锁:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SafeCounter {

    static long counter = 0L;
    static ExecutorService executorService = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        safeCount();
    }

    static void safeCount() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < 10000; j++) {
                    synchronized (SafeCounter.class) {
                        counter++;
                    }
                }
                latch.countDown();
            });
        }
        latch.await();
        System.out.println(volatileCounter);
        executorService.shutdown();
    }
}

经过加锁处理后可以得到预期的结果。注意上面加锁处理在for循环中,一般我们不这么写,应该将加锁处理放到循环体外。这里只是为了说明原子性操作才这么写。

可见性

java内存模型规定,每个java线程可以有自己的工作内存,工作内存是线程私有的,而共享内存(主存)是线程共享的。线程工作内存中会有共享变量的副本,当线程对一个共享变量进行写入时,会先写入线程私有的工作内存,然后再刷新到主存中。
这样就可能会产生一个问题:线程1改变了共享变量的值,在还未刷新到主存时候,线程2去读取这个变量,此时线程2将看不到线程1对这个变量所做的修改。这就是多线程并发带来的数据可见性问题。

java内存模型

java中可以通过申明一个变量为volatile来解决可见性问题。线程读取一个volatile变量时JMM会强制要求线程从主内存中读取,写一个volatile变量时JMM会要求立马刷新到主内存中。java中通过synchronized加锁后的写入也可以保证数据的可见性。
volatile能够解决可见性和有序性但是不能保证原子性,如果需要保证原子性则需要加锁。<font color="#FF0000">这里有一点需要注意的是:volatile类型的long,double变量的读取是原子读取,而非volatile的long,double类型变量读取是非原子读取,所以也可以说volatile在一定程度上解决了原子性问题。</font>

有序性

如果在本线程内观察,所有操作都是有序的,但是如果在一个线程观察另一个线程,所有的操作都是无序的。产生这种问题的根本原因在于"指令重排序"和"工作内存和主内存同步延迟"。java中volatile变量通过内存屏障来防止指令重排序从而保证有序。

java中的锁

上面介绍了多线程并发中的数据安全性问题:原子性、可见性和有序性。java中的锁就是用来保证这三条特性。java中的锁可以分为两大类:synchronized锁和JUC包中的Lock锁。

synchronized锁

synchronized加锁方式

synchronized是jvm中的一个关键字,它有两种使用方式:加在方法上或者代码块上。
加在方法上:

synchronized void foo() {
    //...
}

如果加在方法上且当前方法是非"static"方法,则锁住的是当前类的实例,如果该方法是"static"的,则锁住的是当前类的class对象。
加在代码块上:

void foo() {
    synchronized(lock) {
        //...
    }
}

对于加在代码块的锁,锁住的是'lock'代表的对象。

synchronized锁特性

synchronized锁是JVM提供的内置锁。synchronized锁是非公平的锁,并且是阻塞的,不支持锁请求中断。synchronized锁是可重入的,所谓可重入是指同一个线程获取到某个对象的锁之后在未释放锁之前还可以通过synchronized再次获取锁,而不会阻塞。一个对象在JVM中的内存布局包括对象头、实例数据和对齐填充,synchronized锁就是通过对象头来实现锁的。synchronized还支持偏向锁、轻量级锁和重量级锁。

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。另外,JVM对那种会有多线程加锁,但不存在锁竞争的情况也做了优化,听起来比较拗口,但在现实应用中确实是可能出现这种情况,因为线程之前除了互斥之外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier)

  1. 偏向锁的获取:当一个线程访问同步块并获取锁时,<font color="#FF0000">会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。</font>

  2. 偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word,要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

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

轻量级锁和重量级锁

  1. 轻量级锁,加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程。

  2. 重量级锁:重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

锁膨胀

"JUC"框架提供的锁

java.util.concurrent(JUC)包中主要有locks包和atomic包,locks包中提供了Lock锁,包括可重入锁(ReentrantLock),可重入读写锁(ReentrantReadWriteLock),和StampedLock。atomic包中提供了基于"CAS"(Compare And Set)的乐观锁的一些类。

ReentrantLock

ReentrantLock顾名思义,它是一种可重入锁,其相对synchronized锁而言支持锁中断,公平锁等特性。ReentrantLock源码中涉及加锁主要的方法有:

public ReentrantLock(boolean fair) { // 支持公平锁
    sync = fair ? new FairSync() : new NonfairSync();
}
public void lock() {
    sync.lock();
}

public void lockInterruptibly() throws InterruptedException {
    sync.acquireInterruptibly(1);
}

public boolean tryLock() {
    return sync.nonfairTryAcquire(1);
}

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
    

lock()方法用于同步获取锁,如果获取不到锁,线程将一直阻塞到可以获取锁为止。lockInterruptibly()方法用于同步获取锁,但是这个请求是可以中断的。tryLock()方法不会阻塞等待,如果当前锁没有被其他线程获取,当前线程加锁后返回true,如果当前锁已经被其他线程获取了,则该方法立马返回false,不会阻塞等待。tryLock(long timeout, TimeUnit unit)相对于tryLock()方法多了一个超时机制,如果在指定超时时间之内还没有获取到锁则返回false,不会立马返回false,在超时之前该请求也可以被中断。注意JUC中的Lock需要我们手动释放锁,如果获取锁后方法异常也请记得释放锁(在finally中释放锁),否则其他线程就无法获取锁了。synchronized锁在方法异常时JVM会自动为我们释放锁。这也是两者的不同之处。

ReentrantReadWriteLock

ReentrantLock获取的是排它锁,而ReentrantReadWriteLock是一种读写锁分离的锁。在写锁没有被获取的情况下,多线程并发获取写锁不会出现阻塞,在读多写少的情况下较ReentrantLock有明显的优势。
假设线程1首先获取读锁或者写锁,此时线程2再来请求获取读锁或者写锁的情况如下图:

线程1\线程2
×
× ×

StampedLock

首先StampedLock锁是不可重入的。StampedLock的思想是:<font color="#FF0000">读请求不仅不应该阻塞读请求(对应于ReentrantReadWriteLock),也不应该阻塞写请求。</font>
StampedLock控制锁有三种模式(写,读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。

所谓的乐观读模式,也就是若读的操作很多,写的操作很少的情况下,你可以乐观地认为,写入与读取同时发生几率很少,因此不悲观地使用完全的读取锁定,程序可以查看读取资料之后,是否遭到写入执行的变更,再采取后续的措施(重新读取变更信息,或者抛出异常) ,这一个小小改进,可大幅度提高程序的吞吐量!!
下面是java doc提供的StampedLock一个例子:

class Point {

    private final StampedLock sl = new StampedLock();

    private double x, y;
    
    void move(double deltaX, double deltaY) { // an exclusively locked method
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }
    
    //下面看看乐观读锁案例
    double distanceFromOrigin() { // A read-only method
        long stamp = sl.tryOptimisticRead(); //获得一个乐观读锁
        double currentX = x, currentY = y; //将两个字段读入本地局部变量
        if (!sl.validate(stamp)) { //检查发出乐观读锁后同时是否有其他写锁发生?
            stamp = sl.readLock(); //如果没有,我们再次获得一个读悲观锁
            try {
                currentX = x; // 将两个字段读入本地局部变量
                currentY = y; // 将两个字段读入本地局部变量
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
    
    //下面是悲观读锁案例
    void moveIfAtOrigin(double newX, double newY) { // upgrade
        // Could instead start with optimistic, not read mode
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) { //循环,检查当前状态是否符合
                long ws = sl.tryConvertToWriteLock(stamp); //将读锁转为写锁
                if (ws != 0L) { //这是确认转为写锁是否成功
                    stamp = ws; //如果成功 替换票据
                    x = newX; //进行状态改变
                    y = newY; //进行状态改变
                    break;
                } else { //如果不能成功转换为写锁
                    sl.unlockRead(stamp); //我们显式释放读锁
                    stamp = sl.writeLock(); //显式直接进行写锁 然后再通过循环再试
                }
            }
        } finally {
            sl.unlock(stamp); //释放读锁或写锁
        }
    }
}

基于"CAS"乐观锁的atomic类

java.util.concurrent.atomic包下提供了一些AtomicXXX类,例如:AtomicInteger,AtomicLong,AtomicBoolean等类。这些类通过"CAS"自旋锁来保证线程安全性。相对于JUC locks包中的锁,它不需要挂起和唤醒线程,通过线程"忙自旋"避免系统调用。他的优点是没有系统调用不需要挂起和唤醒线程,他的缺点是会过度占用CPU,无法解决"ABA"问题(ABA问题可以通过AtomicStampedReference类来解决)。

参考资料

《深入理解Java虚拟机》

上一篇下一篇

猜你喜欢

热点阅读