并发编程的三大特性

(更新中)

0. 原子性

0.0 synchronized 保证原子性

synchronized 是 Java 的关键字,是 JVM 级别的锁。它可以有两种形式来控制锁的粒度——修饰方法和修饰代码块。

0.1 CAS 保证原子性

CAS 是乐观锁的一种典型实现。 (Compare and Swap)。即比较并交换。

CAS 是解决多线程并行情况下使用锁造成性能损耗的一种机制,如果内存位置的值 (V) 与预期原值 (A) 相匹配,那么处理器会自动将该位置值更新为新值 (B) 。否则,处理器不做任何操作。CAS 有效地说明了“我认为位置 (V) 应该包含值 (A) 。如果包含该值,则将新值 (B) 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。

当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。(挂起的意思:running state -> suspend()-> blocked state)

比如说有这样一个场景,有一个成员变量 member field state,默认值是 0, 定义了一个方法 doSomething(),判断 state 是否为 0 ,如果为 0,就修改成 1。 这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的 Read - Write 的操作。

一般情况下,我们会在 doSomething() 这个方法上加同步锁 sychronized 来解决原子性问题。

public class Example {
	private int state = 0;
	public void doSth() {
		if (state == 0) {
			state = 1;
			//TODO
		}
	}
}

但是,加同步锁(作为悲观锁),会带来性能上的损耗。我们可以使用 CAS 机制进行优化。

public class CasExample {  
  
    private volatile int state = 0;  
  
    private static final Unsafe UNSAFE = Unsafe.getUnsafe();  
    private static final long stateOffset;  
  
    static {  
        try {  
            stateOffset = UNSAFE.objectFieldOffset(CasExample.class.getDeclaredField("state"));  
        } catch (NoSuchFieldException e) {  
            throw new RuntimeException(e);  
        }  
    }  
      
    public void doSth() {  
        if (UNSAFE.compareAndSwapInt(this, stateOffset, 0, 1)) {  
            //TODO  
        }  
    }  
}

CompareAndSwap 是一个 native 方法,实际上它最终还是会面临同样的问题,就是先从内存地址中读取 state 的值,然后去比较,最后再修改。 这个过程不管是在什么层面上实现,都会存在原子性问题。 所以呢,CompareAndSwap() 的底层实现中,在多核 CPU 环境下,会增加一个 LOCK 指令对总线或者缓存加锁,从而保证比较并替换这两个指令的原子性。


[单多核 CPU 环境下的 CAS:]

多核 CPU 下多个线程同时执行 CAS 操作为什么依旧是线程安全的呢?是因为计算机底层实现保证了V(即上面的变量Var)指向内存的互斥性和立即可见性。

[单核]

查阅 [wiki 比较并交换](比较并交换) - 维基百科,自由的百科全书 (wikipedia.org)):

实现 CAS 操作基于 CPU 提供的原子操作指令实现。对于 Intel X86 处理器,可通过在汇编指令前增加 LOCK 前缀来锁定系统总线,使系统总线在汇编指令执行时无法访问相应的内存地址。而各个编译器根据这个特点实现了各自的原子操作函数。

[多核]

查阅 Lock 的 intel 文档

In a multiprocessor environment, the LOCK# signal ensures that the processor has exclusive use of any shared memory while the signal is asserted. 在多处理器环境中,LOCK# 信号确保处理器在信号被引爆时(被断言时)独占任何共享内存

The LOCK prefix is typically used with the BTS instruction to perform a read-modify-write operation on a memory location in shared memory environment. LOCK 前缀通常与 BTS 指令一起使用,在共享内存环境中对一个内存位置进行读-改-写操作。

Beginning with the P6 family processors, when the LOCK prefix is prefixed to an instruction and the memory area being accessed is cached internally in the processor, the LOCK# signal is generally not asserted. Instead, only the processor’s cache is locked. IA-32架构兼容性 从P6系列处理器开始,当 LOCK 前缀是指令的前缀,并且被访问的内存区域在处理器内部有缓存时,LOCK# 信号一般不被引出。相反,只有处理器的高速缓存被锁定。

Here, the processor’s cache coherency mechanism ensures that the operation is carried out atomically with regards to memory. See “Effects of a Locked Operation on Internal Processor Caches” in Chapter 8 of Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A, the for more information on locking of caches. 在这里,处理器的缓存一致性机制保证了操作是以原子方式进行的,与内存有关。请参阅《英特尔® 64 和 IA-32 架构软件开发人员手册》第 3A 卷第 8 章中的 "锁定操作对内部处理器缓存的影响",以了解关于锁定缓存的更多信息。


ABA 问题

一般的 CAS 在决定是否要修改某个变量时,会判断一下当前值跟旧值是否相等。如果相等,则认为变量未被其他线程修改,可以改。 但是,“相等”并不真的意味着“未被修改”。另一个线程可能会把变量的值从 A 改成 B ,又从 B 改回成 A 。这就是 ABA 问题。 很多情况下,ABA 问题不会影响你的业务逻辑因此可以忽略。但有时不能忽略,这时要解决这个问题,一般的做法是给变量关联一个只能递增、不能递减的版本号。

0.2 ThreadLocal

ThreadLocal 的具体实现原理是,在 Thread 类里面有一个成员变量 ThreadLocalMap。

让我们来做这样一个小实验。

public class ThreadLocalTest {  
  
    static ThreadLocal<Integer> tl = new ThreadLocal<>();  
      
    public static void main(String[] args) {  
        //create thread1  
        Thread t1 = new Thread(()->{  
            //set ThreadLocal 变量的初始值  
            tl.set(1);  
            for (int i = 0; i < 10; i++) {  
                System.out.println(Thread.currentThread().getName()  
                        +"---"  
                        + tl.get()  
                );  
                tl.set(tl.get()+1);  
            }  
        }, "thread1");  
          
        //create thread2  
        Thread t2 = new Thread(()->{  
            tl.set(100);  
            for (int i = 0; i < 10; i++) {  
                System.out.println(Thread.currentThread().getName()  
                        +"---"  
                        +tl.get()  
                );  
                tl.set(tl.get()-1);  
            }  
        }, "thread2");  
          
        //start  
        t1.start();  
        t2.start();  
          
        tl.remove();
    }
}

通过上面的输出结果我们可以发现,线程1 和 线程2 虽然使用的是同一个 ThreadLocal 变量存储值,但是输出结果中,两个线程的值却互不影响,线程1 从1 输出到 10,而线程2 从 100 输出到 91。这就是 ThreadLocal 的功能,即让每一个线程拥有自己独立的变量,多个线程之间互不影响。

ThreadLocal 使用场景比较多,比如在数据库连接的隔离、对于客户端请求会话的隔离等等。近期打算出一个曾经用 threadLocal 当分布式锁解决网约车派单抢单问题的代码示例,欢迎关注我的 Github.

0.2.0 内存泄露

不恰当的使用 ThreadLocal,会造成内存泄漏问题。

主要原因是,线程的私有变量 ThreadLocalMap 里面的 key 是个弱引用。

弱引用的特性,就是不管是否存在直接引用关系,当成员 ThreadLocal 没用其他的强引用关系的时候,这个对象会被 GC 回收掉。从而导致 key 可能变成 null,造成这块内存永远无法访问,出现内存泄漏的问题。

规避内存泄漏的方法有两个:

  • 通过扩大成员变量 ThreadLocal 的作用域(声明成全局变量),避免被 GC 回收。

  • 每次使用完 ThreadLocal 以后,调用 remove() 方法移除对应的数据。

不过第一种方法虽然不会造成key为nul的现象,但是如果后续线程不再继续访问这个key,也会导致这个内存一直占用不释放,最后造成内存溢出的问题。

1. 可见性

2. 有序性

Last updated