就该这么学并发

06. 就该这么学并发 - 如何保证线程安全

2020-07-16  本文已影响0人  码哥说

前言

上节,我们对线程安全有了较全面的认知.

我们知道, 线程之所以不安全, 主要是多线程下对可变的共享资源的争用导致的.

衡量线程是否安全, 主要从三个特性入手

只要保证了这三个特性,我们就认为线程是安全的, 多线程下执行结果才会和单线程执行结果统一起来.

本章,我们就来聊聊如何保证线程安全的问题.

如何保证原子性

常用的保证Java操作原子性的工具是锁和同步方法(或者同步代码块).

我们举个例子:

public class Test {
    private static int count = 0;

    public static void addCount() {
         count++;
    }
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        addCount();
                    }
                }
            });
            thread.start();
        }
        // 主线程睡眠1s,保证子线程都执行完毕
        Thread.sleep(1000);
        System.out.println("count=" + count);
    }
}

可以看出,
子线程计数器累加到1000,
然后主线程创建了10个子线程来跑,
所以,最终结果是应该是10000,
但是大家运行代码看看, 发现各种错误的输出都有!

原因就是 “count++”这个操作不是我们以为的原子操作, 它其实是三步操作

  • 从主存中读取count的值,复制一份到CPU寄存器

  • CPU寄存器中,CPU执行指令对 count 进行加1 操作

  • 把count重新刷新到主存

单线程当然没有问题, 但当多线程时, 就会存在问题.

所以我们必须解决这个问题!

使用锁, 可以保证

同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码.

使用方式

 // 声明一个锁
 private static ReentrantLock lock = new ReentrantLock();

 public static void addCount() {
     lock.lock();
     try {
           count++;
     } finally {
           lock.unlock();
     }
 }

需要强调的是

try{
  //加锁代码
}finally{
  lock.unlock();
}

防止异常导致锁一直无法释放!

同步方法

与锁类似的是同步方法或者同步代码块,
Java使用关键字synchronized进行同步.
需要注意的是, synchronized是有作用范围的.

synchronized的作用范围:

public synchronized void addCount() {
    count++;
}
public static synchronized void addCount() {
    count++;
}
public class Test{
  private Object object = new Object();
  public void addCount() {
      //此时,锁住的是object对象变量
      synchronized (object) {
          count++;
      }

      //此时锁住的是当前实例对象
       synchronized (this) {
          count++;
      }

      //此时锁住的是当前Test类的class对象
       synchronized (Test.class) {
          count++;
      }
  }  
}

无论使用锁还是synchronized, 本质都是一样

通过锁或同步来实现资源的排它性,
从而实际目标代码段同一时间只会被一个线程执行,
进而保证了目标代码段的原子性.

悲观锁和乐观锁

处理数据时,假设会有其他外部修改, 所以每次都会锁住数据, 防止外部的操作. 
处理数据时, 不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止.

初一看, 大家可能会任务乐观锁好像比悲观锁性能高,其实也要看具体场景! 因为乐观锁的重试机制, 所以当并发量很高的时候, 重试的次数就会剧增, 此时, 显然性能是不如悲观锁的!

显而易见, 锁或同步就是悲观锁, 它们以"牺牲性能"来保证原子性.

那么, 有没有无需加锁也能保证原子性的方式呢?

CAS无锁

CAS 是英文单词 Compare And Swap 的缩写,翻译过来就是比较并替换.
CAS有3个操作数,内存值V, 旧的预期值A,要修改的新值B.
当且仅当预期值A和内存值V相同时, 将内存值V修改为B,否则什么都不做.

我们举个例子:

假设 V = 10;
线程1想要使得V的值加1, 按CAS, 此时, A=10, B = 11;
线程2突然修改了V=11;
线程1发现, (A=10) != (V=11), 所以, 不允许更新!

image.png

CAS是一种乐观锁的机制,它不会阻塞任何线程. 所以在效率上,它会比 锁和同步要高.

上文中我们说“count++”自增操作不是原子的, 这导致了并发问题, 那么如何解决呢?

Java提供了并发原子类AtomicInteger来解决自增操作原子性的问题,其底层就是使用了CAS原理

 private static AtomicInteger count = new AtomicInteger();
 public static void addCount() {
        count.incrementAndGet();
 }

CAS虽然在普通场景下优于锁和同步, 但是同时引入了一个“ABA”问题!

ABA问题:
我们还举上一个例子:

假设 V = 10;
线程1想要使得V的值加1, 按CAS, 此时, A=10, B = 11;
线程2突然修改了V=11;
线程3突然修改了V=10;
线程1发现, (A=10) = (V=10), 所以, 允许更新!

虽然数字结果上没有问题, 但是如果需要追溯过程就会存在漏洞!
因为CAS把线程3修改的V=10,当成了V的初始值10, 认为它从未更改过!

针对ABA问题,虽然也能通过增加版本号等等来解决, 不过有句忠告:

使用CAS要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用锁或同步可能更高效

可重入锁

介绍完以上知识,不知道大家关于“锁”的使用,有没有这样的疑惑

A线程对某个对象加锁后, 在A线程内部如果再次要获取同一个对象的锁,会怎样? 会不会死锁?

针对这样的问题, 提出了可重入锁这个东西!

所谓可重入锁,指的是以线程为单位,当一个线程获取对象锁之后,
这个线程可以再次获取本对象上的锁,而其他的线程是不可以的.
(同一个加锁线程自己调用自己不会发生死锁情况)

可重入锁是为了防止死锁

它的实现原理是

通过为每个锁关联一个请求计数和一个占有它的线程.
当计数为 0 时,认为锁是未被占有的.
线程请求一个未被占有的锁时, jvm 将记录锁的占有者,并且将请求计数器置为 1 .
如果同一个线程再次请求这个锁,计数将递增;
每次占用线程退出同步块,计数器值将递减.
直到计数器为0,锁被释放.

synchronized 和 ReentrantLock 都是可重入锁

如何保证可见性

Java提供了volatile关键字来保证可见性.

当使用volatile修饰某个变量时,
它会保证对该变量的修改会立即被更新到内存中,
并且将其它线程缓存中对该变量的缓存设置成无效
因此其它线程需要读取该值时必须从主内存中读取,
从而得到最新的值.

我们还举介绍可见性时的例子,

private static volatile boolean isRuning = false;

如果用volatile来修饰isRuning,
再运行你会发现, 程序能得到预期结果了.

volatile适用于不需要保证原子性,但却需要保证可见性的场景 一种典型的使用场景是用它修饰用于停止线程的状态标记

关于“不需要保证原子性”这点, 大家可以参考介绍“原子性”的那个案例(多线程count++),
将count定义为volatile修饰的变量

private static volatile int count = 0;

运行你会发现最终结果并不是预期值, 原因就在于:

两个线程A,B同时进行count++,
count++是三步操作

  • 从主存中读取count的值,复制一份到CPU寄存器

  • CPU寄存器中,CPU执行指令对 count 进行加1 操作

  • 把count重新刷新到主存

假设count = 1
A和B读取count都是1,复制到各自的缓存中
假设A先执行完了, 将count = 2回写进主存, 因为volatile, 所以通知其它线程count值有更新.
B呢,此时正好执行到最后一步,于是保存的是2,而不是我们认为的3!

如何保证有序性

针对编译器和处理器对指令进行重新排序时,可能影响多线程程序并发执行的正确性问题,

Java中可通过volatile关键字在一定程序上保证顺序性, 另外还可以通过锁和同步(synchronized)来保证顺序性.

事实上, 锁和synchronized即可以保证原子性,也可以保证可见性以及顺序性.因为它们是通过保证同一时间只有一个线程执行目标代码段来实现的.

锁和synchronized可以“胜任”一切,为什么还需要volatile?

synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高;
而volatile开销小很多,
因此在只需要保证可见性的条件下,
使用volatile的性能要比使用锁和synchronized高得多.

除了从应用层面保证目标代码段执行的顺序性外,
JVM还通过被称为happens-before原则隐式地保证顺序性.

两个操作的执行顺序只要可以通过happens-before推导出来,
则JVM会保证其顺序性,
反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率.

happens-before

在JMM(Java内存模型)中,

如果一个操作的执行结果需要对另一个操作可见,
那么这两个操作之间必须要存在happens-before关系,
这两个操作既可以在同一个线程,也可以在不同的两个线程中.

我们需要关注的happens-before规则如下:

如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
一个unlock操作肯定会在后面对同一个锁的lock操作前发生, 锁只有被释放了才会被再次获取
对一个volatile修饰的变量的写操作先行发生于后面对这个变量的读操作
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
Thread对象的start()方法先发生于此线程的其它动作
线程中所有的操作都先行发生于线程的终止检测, 我们可以通过Thread.join()方法结束, Thread.isAlive()的返回值手段检测到线程已经终止执行(所有终结的线程都不可再用)
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
一个对象的初始化完成先行发生于他的finalize()方法的开始

欢迎关注我

技术公众号 “CTO技术”

上一篇 下一篇

猜你喜欢

热点阅读