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

前言

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

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

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

  • 原子性
  • 可见效
  • 有序性

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

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

如何保证原子性

常用的保证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的作用范围:

  • 修饰非静态方法(或成员变量),锁的是this对象, 就是类的实例对象(即: 对象锁)
public synchronized void addCount() {
    count++;
}
  • 修饰静态方法(或成员变量), 锁的是Class对象本身, 因为静态成员不专属于任何一个实例对象 (即: 类锁)
public static synchronized void addCount() {
    count++;
}
  • 修饰代码块时, 锁住的是synchronized关键字后面括号内的对象.
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 都是可重入锁

  • ReentrantLock 表现为 API 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成);

  • synchronized 表现为原生语法层面的互斥锁.

如何保证可见性

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变量规则
对一个volatile修饰的变量的写操作先行发生于后面对这个变量的读操作
  • 程序次序规则
一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 线程启动规则
Thread对象的start()方法先发生于此线程的其它动作
  • 线程终结原则
线程中所有的操作都先行发生于线程的终止检测, 我们可以通过Thread.join()方法结束, Thread.isAlive()的返回值手段检测到线程已经终止执行(所有终结的线程都不可再用)
  • 线程中断规则
对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则
一个对象的初始化完成先行发生于他的finalize()方法的开始

欢迎关注我

技术公众号 “CTO技术”

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271