Java多线程-二

前言

这一篇主要介绍了volatile关键字的基本用法,使用场景等等。在讲述volatile之前,还得补充一些预备知识,比如Java内存模型,并发编程中常见的问题。这些应该如何解决,volatile会解决哪些问题,如何解决等等。

内存模型

image-20200424003029285

程序数据的临时数据存放在内存中,但由于CPU执行速度很快,使得从内存读写数据的效率成为了瓶颈,因而CPU当中还有一个高速缓存Cache(程序运行时会从主存复制一份数据到Cache中)对于单线程程序,这种模式不会出错。线程先从主存读取值,复制一份到Cache,CPU指令进行操作,将新值写入Cache,最后将Cache新值刷新到主存当中。

但对于多线程程序,两个线程可能同时读取值到各自所在CPU的Cache,然后进行操作。因为Cache的值更新并不会立刻通知其他CPU的Cache失效,所以就会出现缓存不一致的错误。

一般的解决方法:synchronized,Lock,volatile

并发编程常见问题

原子性:一系列的操作,要么都执行,要么都不执行。

可见性:一个线程修改了某一遍历的值,其他线程能否立刻看到修改的值。如果不能,那么就会出现操作丢失的问题。

有序性:JVM执行代码时不一定按顺序执行,会发生指令重排序(Instruction Reorder),处理器为了提高效率,会对代码的执行顺序进行优化,但会保证结果一致。(只能保证单线程下是一致,多线程下可能出现错误)

Java内存模型

概念:所有变量都存在于主存中,每个线程都有自己的工作内存(相当于前面的高速缓存Cache)。线程对变量的所有操作都必须在工作内存中进行,不能直接对主存进行操作。每个线程不能访问其他线程的工作内存。

Java的原子性

在Java中,很多操作都不是原子性操作,即使是简单的一个语句也很有可能是多个操作。

int x = 10;		// statement 1
int y = x;		// statment 2
x++;			// statement 3
x = x + 1;		// statement 4

上述语句,只有语句1是原子性操作,其他都不是。

语句2包含两个操作,先去读取x的值,然后再将x的值写入工作内存。

语句3,4包含三个操作,先读取x的值,进行加1操作,然后写入新值到工作内存。

因此:Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。

Java的可见性:可以使用 volatile 关键字来保证可见性。当一个共享变量被volatile修饰,它会保证修改的值会立即被更新到主存,并且此时其他线程的Cache都会失效。如果其他线程需要读取该值,那么就要重新到内存中读取,此时读取到的会是新值。而普通的共享变量,在被修改之后,什么时候被写入主存是不确定的,比如A线程对a变量进行了修改,然后后面还有其他逻辑,这段时间内B线程再去读取a,因为A线程对a的修改还没有写入主存,因而B线程读取到的仍然是旧值。

通过synchronized和Lock也能保证可见性,但实际上volatile也能实现。

Java的有序性:在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。重排序有一个 happens-before 原则,只有当两个操作之间没有必然的执行顺序,JVM才可对其进行重排序。

例子:

int a = 10;
int r = 2;
a = a + 3;
r = a * a;

执行顺序不一定是1→2→3→4,也可能是2→1→3→4,但不可能是2→1→4→3。在单线程下不会出现问题,但后面我们会看到,在多线程下会因为重排序而可能出错。

volatile

作用

保证了可见性,有序性,但不能保证原子性。它的效率比synchronized高,所以特定情况下优于synchronized,比如通过volatile+CAS实现无锁并发。

Question:volatile如何保证可见性和有序性?

Ans:对于有序性,volatile使得修改的值会立刻更新到主存,其他线程的Cache立即失效,如果需要读取该变量的值,那么需要从主存读取更新后的值。

而对于有序性,volatile变量前的语句不会重排到volatile变量之后,反之同理。而且对于单一的一个语句,写操作一定会发生在读操作之前。虽然语句顺序依然可能出现重排,但不会影响到volatile变量,因而在多线程环境下也不会出错。

举例子说明:

// Thread1
context = loadConext();		// 语句1
init = true;			   // 语句2
// Thread2
while (!init) {
    sleep(10000);
}
doSthWithContext(context);

上述例子并不完整,但大概是这个意思:init值默认是false,此时线程2一直在进行等待其他线程把init值更改。因此,当线程1执行完loadConext操作,在这里是加载配置文件,将属性赋值到context,接着就把init更改为true。这表示context已经加载完毕,其他线程可以使用这个context了。于是此时线程2结束了while循环,进行了它的doSthWithContext方法,传入的是已经被初始化的context变量。这个看起来一定不会出错,实际上并不是。

由于重排序,显然,语句1和语句2是没有任何关联的。在单线程环境下,先执行loadContext,还是先执行init的赋值,都不会对后续的操作有任何影响。因此执行顺序有可能是:先执行语句2,再执行语句1。那么多线程下,这个重排序会导致什么问题?先执行了init的赋值,此时线程2就跳出了while循环,继续执行它的doSth方法。然而此时线程1还没有完成loadContext方法,即context还没有被初始化。那么在线程2的doSth方法就会出错。

解决方法:使用volatile修饰init变量,那么语句1,语句2的顺序就不会改变。因为:volatile变量前的语句不会重排到volatile变量之后。

Question:为什么volatile不能保证原子性?

Ans:以最简单的 i++为例:

public class Test {
    public volatile int inc = 0;
    public void incre() {
        inc++;
    }
}

incre方法的字节码:

public void incre();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field inc:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field inc:I
      10: return

volatile只能保证,在getfield时,inc的值是正确的。但后续执行iconst,iadd操作时,其他线程可能已经把inc的值改变。比如线程1从内存读取了inc的值,然后阻塞。接着线程2对inc的值进行修改,它会使得线程1的缓存(工作内存)失效。但线程1唤醒后是继续执行后面的操作,而不会重新进行getfield,因而就出现了更新丢失。(根本原因:Java操作的非原子性)

volatile常用场景:

①标记状态量。可见性使得flag可以尽早更新,生效并作用于其他线程。同时又防止了指令重排序导致标记量的值在并发时出错。

②double check。双重检测,这里以实现单例模式为例:

代码一(有误):

public class Singleton {
    public static Singleton singleton;
    private Singleton() {
        
    }
    
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized(Singleton.class) {
                if (singleton == null) {
                    // double check是必须的,否则假设Thread1正在等待锁
                    // 当Thread2生成了对象后,Thread1会再次创建
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

看起来可以在多线程下也能高效运作(只有为null时才进入synchronized体),但由于重排序,而new这一个步骤实际上有3步:①分配内存空间→②初始化对象→③对象引用指向刚刚分配完的空间。而重排序下,有可能变成了:①→③→②,在多线程环境下,可能会出错,如下:

image-20200424011712469

可以看到,Thread B可能会获得一个初始化不完整的对象,导致出错。

因此更正确的做法是:将单例变量声明为volatile,使得写操作会发生在读操作之前,所以一定是①→②→③。

正确代码:public volatile static Singleton singleton;,其他都不变。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值