谈谈对线程安全的理解

JMM内存模型与Volatile的友谊

声明:本篇以借用大话设计模式的风格开展,纯粹知识分享,不商用不牟利,如有侵权请联系作者删除

一年一度的金三银四开始了,小菜也开始了面试之旅,再一次面试受挫后,发生了以下故事

大鸟:怎么了小菜,今天不是去面试了吗,怎么回来垂头丧气的,是不是遇到挫折了?

小菜:唉,别提了,原本觉的设计模式和框架都懂了,面试能十年九稳的,结果上来第一个问题就给我难住了,面试的一个小时里,每一分钟都是煎熬。

img

大鸟:别气馁,面试就是一个成长过程的验证,筛选出不会的部分进行加强,真好我现在有时间,来来来,聊一聊都有哪些把你难住的问题。

小菜:第一个问题是,谈谈对线程安全的理解,我是这么回答的:

- 线程安全,其实是内存安全,对事共享内存,可以被所有线程访问。
- 当多个线程访问一个对象,如果不需要额外的控制,调用的找个对象行为都是正确的,通常可以认为是线程安全的;
- 如果多个线程访问一个对象,每个线程所修改的内容都会覆盖其他线程产生的数据,且对象行为不正确,通常可以认为是非线程安全的;

大鸟:说的很好啊,描述的很到位,第二个问题是啥?

小菜:第二个是谈谈内存堆栈是什么意思,我是说这么说的

- 堆是进程和线程的共有控件,分全局堆和局部堆,全局堆是没有分配的控件,局部堆是操作系统对进程初始化时所分配的空间,当然,在运行过程中也可以申请额外的堆,用完之后要归还操作系统,如果没有成功归还就会出现内存泄漏问题,俗称:“坏账”;
​
- 栈是每个线程独有的,保存其运行状态和局部自动变量的,栈在开始的时候初始化,每个线程的栈相互独立,因此栈是线程安全的,操作系统会在切换线程时,自动切换栈,占内存不需要再高级语言中显示的分配和释放。

大鸟鼓起掌

大鸟:小菜你说的太好了,这回答的也没毛病, 怎么难住你了?

小菜:大鸟别急,这问题在第三个问题之后,第三个问题是,并发和并行分别是什么意思:

- 并发:指在同一时刻,只能有一条指令执行。
- 并行:指在同一时刻,有多条指令在多个处理器上同时执行。
​
- 并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中
存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多
个操作(每个小时间片执行一个操作,多个操作快速切换执行)。

大鸟不停的竖起大拇指头

大鸟:爆赞,这些概念都记得很清楚,比大鸟我都厉害。

小菜:大鸟你可别挖苦我了,后面的并发三大特性,可给我难住了。

img

并发编程Bug的源头:可见性、原子性和有序性问题

大鸟:哦?三大特性不是可见性、原子性和有序性么?怎么就给你难住了?

小菜:三大特性我也知道,我还知道解决方案,我是这么回答的:

  • 可见性

        当一个线程修改了共享变量的值,其他线程能够看到修改的值,Java内存模型是通过在变量修改后将新值同步会主内存,在变量读取前从主内存刷新值,这种依赖主内存作为传递媒介的方法实现可见性
    • 可以通过以下方案解决

      • 通过 volatile 关键字保证可见性。

      • 通过 内存屏障保证可见性。

      • 通过 synchronized 关键字保证可见性。

      • 通过 Lock保证可见性。

      • 通过 final 关键字保证可见性

  • 有序性

        即程序执行的顺序按照代码的先后顺序执行,JVM存在指令重排,所以存在有序性问题,其实不止是JVM,CPU指令优化器也会重排
    • 可以通过以下方案解决

      • 通过 volatile 关键字保证有序性

      • 通过内存屏障保证有序性

      • 通过synchronized关键字保证有序性

      • 通过Lock保证有序性

  • 原子性

        一个或多个操作,要么全部执行且执行过程中不被任何因素打扰,要么全部不执行。在Java中,对基本数据类型变量的读取、赋值操作都是原子性的(64位处理器,32位处理器会分段)。不采取任何的原子性保障措施的自增操作并不是原子性的。
    • 可以通过以下方案解决

      • 通过synchronized关键字保证原子性

      • 通过Lock保证原子性

      • 通过Lock保证原子性

    思考问题:在32位机器上对long型变量进行加减操作,是否存在隐患?

        线程切换带来的原子性问题
        
        非 volatile 类型的 long 和 double 型变量是 8 字节 64 位的, 32 位机器读或写这个变量的时候把它们分成两个 32 位操作,可能一个线程读取了某个值的高 32 位,低 32 位被另一个线程修改了。
        所以 Java 官方推荐最好把 long/double 变量声明为 volatile 或是同步加锁 synchronized 以避免并发问题

大鸟露出了欣慰的笑容

大鸟:行啊小菜,对你刮目相看啊。

小菜:大鸟你别挖苦我了,后面就是难住我的问题了,如下:

上面说到了使用volatile解决可见性问题,那么请谈一谈volatile是如何解决可见性问题的

大鸟:我明白了,原来考察的是对JMM内存模型的理解啊。

大鸟露出了了然于胸的表情

小菜:好像是个什么模型来着,大鸟快我讲讲什么是JMM内存模型, 以及volatile是如何解决可见性问题的,我不想下次面试再被面试官吊打

img

大鸟:想要完全明白这个问题,需要先了解下JMM内存模型,以及为什么有可见性问题,老规矩,先上图:

大鸟快速的画出了一个Java共享内存模式的图

image-20211215111045969

小菜露出了茫然的表情

大鸟:小菜是不是看懵了,来我帮你捋一捋,这个是线程安全的,按照顺序执行会得到正确结果,过程如下:

如上图:
“线程1”从“主内存”中读取“a=1”,运算“a=a+1”,得到结果“2”,运算结束将数据回写“主内存”;
“线程2”从“主内存”中读取“a=2”,运算“a=a+5”,得到结果“7”,运算结束将结果回写“主内存”;
主内存中的变化过程:“a=1”-> 线程1操作后“a=2” -> 线程2操作后“a=7”;

小菜的茫然一扫而空,逐渐兴奋起来

小菜:原来java内存模型是这样子运行的,这个是线程安全的,那怎么出现的非线程安全呢?

大鸟:小菜莫急,来看下一张图

大鸟在原来的图上进行修改

image-20211215112843610

大鸟:小菜,你看这张图和上一张图有什么区别

小菜:执行步骤1.3和1.4互换了,第一张图时,先执行的是线程1回写主内存,再执行的线程2读数据,这一张图颠倒了

大鸟:不愧是小菜,眼光毒辣,再来捋一捋

如上图,程序执行过程是这样的: 
1、“线程1”从主内存读取到“a=1”,这时“线程1”中“a=1”,主内存中“a=1”
2、“线程1”运算“a=a+1”,这时“线程1”中“a=2”,主内存中:“a=1”
3、“线程2”从主内存读取到“a=1”,这时“线程2”中“a=1”,主内存“a=1”,“线程1”中“a=2”
4、“线程1”运算完成回写主内存“a=2”,这时主内存中“a=2”,“线程2”中“a=1”
5、“线程2”运算“a=a+5”,这时“线程2”中“a=6”,主内存中“a=2”
6、“线程2”运算完成回写主内存“a=6”,这是主内存中“a=6”

大鸟:根据以上的运行过程分析,“线程1”回写主内存后,“线程2”是不知道的,而且“线程2”中有“a=1”的数据,所以会优先使用“a=1”进行运算,结束回写主内存“a=6”,但是我们想要的结果是“a=7”;可以认定这段逻辑是非线程安全的

小菜露出了崇拜的表情

img

小菜:不愧是大鸟,这下我明白了为什么会有线程安全问题,多个线程操作时,因为并发情况下各自读到的数据在变化后可能相互不知道,导致最终结果不及预期,这个就是“可见性”问题,不过volatile是怎么解决可见性问题呢?

大鸟:小菜真是一点就透,接下来说volatile是怎么解决的可见性问题,简单来说如下:

- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中,读取共享变量

小菜:大鸟你说的这些好抽象,我好晕啊

大鸟:哈哈,小菜别着急,我把上面的图改一下你就明白怎么回事了

只见大鸟将图进行了修改,最终结果如下

image-20211215113940412

小菜灵机一动

小菜:我明白了大鸟,“线程1”执行完之后,立刻将结果回写主内存,并把“线程2”中的“a=1”清理掉了,“线程2”在运算时,发现没有“a”的值了,会从主内存中再读一次,取到了“a=2”,再运算到的最终结果是“a=7”,符合预期。

大鸟:小菜真棒,一点就透,volatile就是通过这种清理重读的方式保证了数据的可见性

小菜:不过我还是没明白,volatile是怎么做到立刻回写主内存和清理其他线程的工作内存呢?

大鸟:到这个层次,讲解不如看代码,先来简单介绍下:

	volatile有两种解释器,一种是字节码解释器(代码),一种是模板解释器(指令)。
	
	JVM中的字节码解释器(bytecodeInterpreter),用C++实现了JVM指令,其优点是实现相对简单且容易理
解,缺点是执行慢。
	模板解释器(templateInterpreter),其对每个指令都写了一段对应的汇编代码,启动时将每个指令与对应
汇编代码入口绑定,可以说是效率做到了极致。

大鸟:先来看看字节码解释器的源码:

bytecodeInterpreter.cpp

// Now store he result
int field_offset = cache->f2_as_index();
// *如果是volatile
if (cache->is_volatile()) {
	if (tos_type == itos) {
		obj->release_int_field_put(field_offset, STACK_INT(-1));
	} else if (tos_type == atos) {
		VERIFY_OOP(STACK_OBJECT(-1));
		obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
		OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
	} else if (tos_type == btos) {
		obj->release_byte_field_put(field_offset, STACK_INT(-1));
	} else if (tos_type == ltos) {
		obj->release_long_field_put(field_offset, STACK_LONG(-1));
	} else if (tos_type == ctos) {
		obj->release_char_field_put(field_offset, STACK_INT(-1));
	} else if (tos_type == stos) {
		obj->release_short_field_put(field_offset, STACK_INT(-1));
	} else if (tos_type == ftos) {
		obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
	} else {
		obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
	}
    // *调用内存屏障,storeload
	OrderAccess::storeload();
}

在linux系统x86中的实现 (注:x86处理器中利用lock实现类似内存屏障的效果。)

orderAccess_linux_x86.inline.hpp

inline void OrderAccess::storeload() { 
    fence(); 
}
inline void OrderAccess::fence() {
    if (os::is_MP()) {
        // always use locked addl since mfence is sometimes expensive
        #ifdef AMD64
             __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
        #else
            __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
        #endif
        }
}

大鸟:再来看模板解释器

templateTable_x86_64.cpp

void TemplateTable::volatile_barrier(Assembler::Membar_mask_bits
  order_constraint) {
    // Helper function to insert a is‐volatile test and memory barrier
   // Not needed on single CPU
   // 非单核cpu
    if (os::is_MP()) {
        __ membar(order_constraint);
    }
}

 // *负责执行putfield或putstatic指令
void TemplateTable::putfield_or_static(int byte_no, bool is_static, RewriteControl rc){
     // *Check for volatile store
    __ testl(rdx, rdx);
    __ jcc(Assembler::zero, notVolatile);
    
    putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);
    volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad |
    Assembler::StoreStore));
    __ jmp(Done);
    __ bind(notVolatile);
    putfield_or_static_helper(byte_no, is_static, rc, obj, off, flags);

    __ bind(Done);
}

assembler_x86.hpp

// Serializes memory and blows flags
void membar(Membar_mask_bits order_constraint) {
// We only have to handle StoreLoad
// * x86平台只需要处理StoreLoad
    if (order_constraint & StoreLoad) {
        int offset = ‐VM_Version::L1_line_size();
        if (offset < ‐128) {
            offset = ‐128;
        }
        // *下面这两句插入了一条lock前缀指令: lock addl $0, $0(%rsp)
        // *lock前缀指令
        lock(); 
        // *addl $0, $0(%rsp)
        addl(Address(rsp, offset), 0); 
    }
}

大鸟:小菜,你有没有看到什么门道?

小菜:两种解释器都是storeLoad,最终都是执行的lock();addl(), 这两个是啥意思?

大鸟:哈哈,小菜果然聪明,一下子就发现了,storeLoad是内存屏障的写读屏障,内存屏障后面再解释,lock指令是操作系统的指令,实现了类似于内存屏障的效果,官方解释如下:

1、为确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中。intel使用缓存锁定来保证指令的原子性,缓存锁定将大大降低lock前缀指令的执行开销。缓存锁定的颗粒度更小。

2、lock前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面指令重排序。

3、lock前缀指令会等待他之前所有的指令完成,并且所有缓冲的写操作协会缓存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效

小菜一脸懵逼

小菜:官方的解释好抽象啊,我一时间理解不了是啥意思。

img

大鸟:小菜别晕,我来给你捋一捋

1、lock指令,会立刻将内容回写主内存,期间加锁只有当前线程才能写,其他的写和读都等待;
2、写完之后,将其他线程内存中的数据失效清理掉;
3、其他线程再读取的时候,只能到主内存中再读取一次新的值,一次保证了可见性。

小菜:我明白了,不过我们说的这些都是概念类的,怎么证明

大鸟:小菜果然务实,不过大鸟我也有准备,如下:

添加下面的jvm参数查看之前可见性Demo的汇编指令,可以看到在执行时调用了lock前缀指令

‐XX:+UnlockDiagnosticVMOptions ‐XX:+PrintAssembly ‐Xcomp

image-20211215135216929

注:从硬件层面分析下lock前缀指令

《64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf》中有如下描述:

	32位的IA-32处理器支持对系统内存中的位置进行锁定的原子操作。这些操作通常用于管理共享的数据结构(如信号量、段描述符、系统段或页表),在这些结构中,两个或多个处理器可能同时试图修改相同的字段或标志。处理器使用三种相互依赖的机制来执行锁定的原子操作:
1、有保证的原子操作
2、总线锁定,使用LOCK#信号和LOCK指令前缀
3、缓存一致性协议,确保原子操作可以在缓存的数据结构上执行(缓存锁);这种机制出现在Pentium4、Intel Xeon和P6系列处理器中

小菜满脸崇拜

img

小菜:大鸟太厉害了,现在已经明白了volatile是如何解决的可见性问题了,那有序性是什么?是不是也和lock有关?

大鸟:小菜也很棒,都会举一反三了,有序性就是为可见性服务的,和有序性相关的概念是“指令重排序”,啥意思呢,来看看官方怎么介绍的:

	java语言规范规定JVM线程内部位置顺序化语义。即只要程序的最终结果与他顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
   指令重排序的意义:JVM能根据处理器特性(CPU多级缓存系统、多喝处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

小菜若有所思

小菜:那这个指令重排序的好处应该是,提升机器性能,不过不明白具体是什么样的。

大鸟:哈哈,我再画个图,帮小菜好好捋一捋,指令重排序前,如下:

只见大鸟又画了一个图

image-20211215140125895

大鸟:小菜你看,程序按照图中红色箭头方向执行,指令执行过程如下:

	图中左侧是 3 行 Java 代码,右侧是这 3 行代码可能被转化成的指令。	
	可以看出 a = 100 对应的是 Load a、Set to 100、Store a,意味着从主存中读取 a 的值,然后把值设置为 100,并存储回去,同理, b = 5 对应的是下面三行 Load b、Set to 5、Store b,最后的 a = a + 10,对应的是 Load a、Set to 110、Store a。

	如果你仔细观察,会发现这里有两次“Load a”和两次“Store a”,说明存在一定的重排序的优化空间。

大鸟:再来看看指令重排之后的执行情况

大鸟将图修改后

image-20211215140920825

- 重排序后, a 的两次操作被放到一起,指令执行情况变为 Load a、Set to 100、Set to 110、 Store a。
- 下面和 b 相关的指令不变,仍对应 Load b、 Set to 5、Store b。
- 可以看出,重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a。
- 重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。

大鸟:现在明白了什么是“指令重排序”以及重排序的好处了吧,就是为了提高效率,再不影响最终结果的前提下,处理编译器的重排序优化,CPU也可能会重排序优化;

小菜:为什么“指令重排序”好处这么大,又不影响最终结果,那为什么还要“禁用”呢?

大鸟:这个问题很好,问到点子上了,老规矩,先上个demo代码看下结果:

大鸟快速打了一段验证代码

private static int x = 0, y = 0;
private static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException{
    int i=0;
    while (true) {
        i++;
        x = 0;
        y = 0;
        a = 0;
        b = 0;
        /**
         *   x,y: 0
         *  初始 x=0,y=0
         *  按照代码顺序执行,输出的结果应该是x=1,y=0,或x=0,y=1
         *  如果出现了指令重排,则会出现x=0,y=0
         */
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                shortWait(20000);
                a = 1;
                x = b;
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                b = 1;
                y = a;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("第" + i + "次(" + x + "," + y + ")");
        if (x == 0 && y == 0) {
            break;
        }
    }
}

public static void shortWait(long interval) {
    long start = System.nanoTime();
    long end;
    do {
        end = System.nanoTime();
    } while (start + interval >= end);
}

大鸟:来看下这段代码的执行结果

经过半天的运行之后

image-20211215142752166

大鸟:上面的代码,如果按照代码顺序执行,线程1:a = 1 -> x = b;线程2:b = 1 -> y = a; 无论先执行哪个线程,结果都应该是x=1, y=0或x=0,y=1,但是将这个执行次数放大,会发现结果出现了x=0,y=0,出现重排了。

我们在开发中,一定要考虑到重排序带来的后果。

小菜:那volatile是如何解决这个问题的呢?和lock前缀指令有什么关系呢?

大鸟:问得好,让我修改下前面的图

image-20211215143033784

大鸟:实现的方式就是使用内存屏障, 相当于使用Lock在每一条要执行的指令上插入内存屏障,指令中间加入写读屏障,在前面的指令执行完之后,后面的指令才能执行,以此解决了禁用了重排序

小菜:我懂了,大鸟现在是不是应该解释下内存屏障是什么了?

大鸟:哈哈,小菜真是认真,来看下图:

image-20211215143046203

JMM内存屏障插入策略

1. 在每个volatile写操作的前面插入一个StoreStore屏障
2. 在每个volatile写操作的后面插入一个StoreLoad屏障 
3. 在每个volatile读操作的后面插入一个LoadLoad屏障 
4. 在每个volatile读操作的后面插入一个LoadStore屏障

JMM层面的内存屏障

在JSR规范中定义了4种内存屏障: 
	LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前, 保证Load1要读取的数据被读取完毕。 
	LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1 要读取的数据被读取完毕。 
	StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的 写入操作对其它处理器可见。 
	StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证 Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是 个万能屏障,兼具其它三种内存屏障的功能 
	由于x86只有store load可能会重排序,所以只有JSR的StoreLoad屏障对应它的mfence或lock前缀指令, 其他屏障对应空操作

硬件层内存屏障

	硬件层提供了一系列的内存屏障 memory barrier / memory fence(Intel的提法)来提供一致性的能 力。拿X86平台来说,有几种主要的内存屏障:
	1. lfence,是一种Load Barrier 读屏障 
	2. sfence, 是一种Store Barrier 写屏障 
	3. mfence, 是一种全能型的屏障,具备lfence和sfence的能力 
	4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速 缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。 
	内存屏障有两个能力: 
		1. 阻止屏障两边的指令重排序 
		2. 刷新处理器缓存/冲刷处理器缓存 
	对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数 据;对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。 
	Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速 缓存中的数据刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。 
	不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的 平台生成相应的机器码。

大鸟:怎么样,小菜,有没有理解

小菜:今天学了好多,我要好好消化一下了

大鸟:哈哈,好,多复习一下,争取下次面试能跟面试官上一课


img

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值