浅谈Java线程安全

时间:2023-03-17 22:43:40

浅谈Java线程安全

- - 2019-04-25    17:37:28

线程安全

Java中的线程安全

按照线程安全的安全程序由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类。

1.1 不可变

在Java语言里面,不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何的线程安全保障措施。

如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。还java.lang.String类的对象。

1.2 绝对线程安全

绝对的线程安全完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的,甚至是不切实际的代价。
在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

1.3 相对线程安全

相对的线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

1.4 线程兼容

线程兼容是指对象本身并不是线程安全的,但是可能通过在调用端正确地使用同步手段来保证对象在并发环境中安全地使用,我们平常说一个类不是线程安全的,绝大多数指的都是这种情况。

1.5 线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于Java语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

线程安全的实现方法

2.1 不可变

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

不可变的类型:

1)  
final 关键字修饰的基本数据类型

2)  
String

3)  
枚举类型

4)  
Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。

对于集合类型,可以使用
Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

publicclass
ImmutableExample {

    publicstaticvoid
main(String[]
args) {

        Map<String,Integer> map = new
HashMap<>();

        Map<String, Integer> unmodifiableMap =
Collections.unmodifiableMap(
map);

        unmodifiableMap.put("Mr_Zhangxd", 1);

    }

}

Exception in
thread "main"
java.lang.UnsupportedOperationException

    at
java.util.Collections$UnmodifiableMap.put(Unknown Source)

    at org.zxd.com.ImmutableExample.main(ImmutableExample.java:11)

Collections.unmodifiableXXX()
先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。

public V
put(K
key,V value) {

        thrownew
UnsupportedOperationException();

}

2.2 互斥同步

互斥同步(Mutual Exclusion & Synchroniztion)是最常见的一种并发正确性保障手段。

同步
-
指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,使用信号量的时候)线程使用。

互斥
-
是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

互斥是因,同步是果,互斥是方法,同步是目的。

在Java里面,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,却取对应的对象实例或Class对象来作为锁对象。

根据虚拟机规范的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放了。
如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
在虚拟机规范对monitorenter和monitoreexit的行为描述中,有两点是需要特别注意的。

    1. synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
    2. 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。

除了synchronized之外,我们还可以使用java.util.concurrent名中的重入锁(ReentrantLock)来实现同步,在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别。不过ReentrantLock比synchronized增加了一些高级功能,主要有以下三项:

    1. 等待可中断 - 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
    2. 公平锁 - 多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
    3. 锁绑定多个条件 - 一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。

单核处理器下两种锁的吞量对比图

浅谈Java线程安全

JDK1.5、双Xeon处理器下两种锁的吞吐量对比

浅谈Java线程安全

从上面两个图可以看出,多线程环境下synchronized的吞吐量下降得非常严重,而ReentrantLock则能基本保持在同一个比较稳定的水平上。

总结 - 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。

2.3 非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

1、CAS

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。

2、AtomicInteger

J.U.C 包里面的整数原子类
AtomicInteger 的方法调用了 Unsafe 类的
CAS 操作。

以下代码使用了 AtomicInteger 执行了自增的操作。

private
AtomicInteger
cnt = new
AtomicInteger();

    publicvoid add() {

        cnt.incrementAndGet();

    }

以下代码是 incrementAndGet() 的源码,它调用了 Unsafe 的 getAndAddInt() 。

publicfinalint
incrementAndGet() {

        return unsafe.getAndAddInt(this,valueOffset,1)+1;

    }

以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用
compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到
getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。

publicfinalint
getAndAddInt(Object
var1,longvar2,intvar4) {

        intvar5;

        do {

            var5 = this.getIntVolatile(var1,
var2);

        }while(!this.compareAndSwapInt(var1,var2,var5,var5+var4))
{

            return var5;

        }  

    }

3、ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

2.4 无同步方案

    1. 栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

publicclass StackClosedExample {

    publicvoid Add() {

       intcnt = 0;

       for(inti = 0;i < 100;i++) {

           cnt ++;

       }

       System.out.println(cnt);

    }

}

publicstaticvoid main(String[] args) {

       // TODO Auto-generated method stub

       StackClosedExample
example = new StackClosedExample();

       ExecutorService
executorService = Executors.newCachedThreadPool();

       executorService.execute(() -> example.Add());

       executorService.execute(() -> example.Add());

       executorService.shutdown();

}

运行结果:100

              100

2、线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而
thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

publicclass ThreadLocalExample {

    publicstaticvoid main(String[] args) {

       // TODO Auto-generated method stub

       ThreadLocal
threadlocal = new ThreadLocal();

       Thread
thread1 = new Thread(()->{

           threadlocal.set(1);

           try {

              Thread.sleep(1000);

           }catch(InterruptedException e) {

              e.printStackTrace();

           }

           System.out.println(threadlocal.get());

           threadlocal.remove();

       });

       Thread
thread2 = new Thread(() ->{

           threadlocal.set(2);

           threadlocal.remove();

       });

       thread1.start();

       thread2.start();

    }

}

运行结果:1

为了理解 ThreadLocal,先看以下代码:

public static void main(String[] args) {

       //
TODO Auto-generated method stub

       ThreadLocal
threadlocal1 = new ThreadLocal();

       ThreadLocal
threadlocal2 = new ThreadLocal();

       Thread
thread1 = new Thread(()-> {

           threadlocal1.set(1);

           threadlocal2.set(1);

       });

       Thread
thread2 = new Thread(()->{

           threadlocal1.set(2);

           threadlocal2.set(2);

       });

       thread1.start();

       thread2.start();

}

}

它所对应的底层结构图为:

浅谈Java线程安全

每个 Thread 都有一个
ThreadLocal.ThreadLocalMap 对象。

ThreadLocal.ThreadLocalMapthreadlocals = null;

当调用一个
ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLocal->value
键值对插入到该 Map 中。

publicvoid set(X value) {

           Thread
t = Thread.currentThread();

           ThreadLocalMap
map = getMap(t);

           if(map != null) {

              map.set(this,value);

           }else {

              createMap(t,value);

           }

       }

get() 方法类似。

public X get()
{

           Thread
t = Thread.currentThread();

           ThreadLocalMap
map = getMap(t);

           if(map != null) {

              ThreadLocalMap.Entry
e = map.getEntry(
this);

              if(e != null) {

                  X
result = (X)e.value;

                  return result;

              }

           }

           return setInitialValue();

       }

ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。

在一些场景 (尤其是使用线程池)
下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal
后手动调用 remove(),以避免出现
ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。

3、可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。