多线程基础(三)线程安全解决方案

1、线程安全

当多个线程访问某个对象时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

1.1、线程安全问题产生的原因

在操作系统中CPU的速度最快,内存次之,磁盘最慢,CPU资源又是最宝贵的,所以为了平衡三者之间的速度,做出如下优化:

  1. CPU增加了高速缓存,均衡与内存的速度差异
  2. 操作系统增加进程,线程,分时复用CPU,均衡CPU与IO设备的速度差异
  3. 编译程序优化指令的执行顺序,是的能够更加合理的利用缓存。

1.2、可见性问题

当多个线程在不同CPU上运行的时候,CPU各自的私有高速缓存互相不可见。
可见性问题示例:

private static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    new Thread(()->{
        while (flag){
        }
        System.out.println("线程终止");
    }).start();
    Thread.sleep(1000);
    flag = false;
}

1.3、原子性问题

CPU时间片的切换导致程序出现原子性问题
原子性问题示例,其计算结果必然少于等于1000:

public static int count = 0;

public static void incr() {
    try {
        Thread.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    count++;
}

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 1000; i++) {
        new Thread(() -> Count.incr()).start();
    }
    Thread.sleep(2000);
    System.out.println("结果:" + count);
}

上面的代码中,产生原子性问题的是count++,它不是一次性操作完成的,在多线程并发条件下,可能会出现在计数之前切换线程导致的计数丢失。
在这里插入图片描述

1.4、有序性问题

在程序执行的时候,可能出现指令重排序的问题,这些重排序会导致可见性问题。

  1. 编译器优化重排序
  2. 指令级并行重排序
  3. 内存系统重排序

2、解决方案(java内存模型)

java内存模型(JMM)是一种抽象结构,它提供了合理的禁用缓存以及禁用重排序的方法来解决可见性,有序性问题。

2.1、锁(Synchronized)

Synchronized加锁的范围

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是Synchronized括号里配置的对象
public class SynchronizedDemo {
	//实例对象级别
    synchronized void method1() {}
	//实例对象级别
    void method2() {
        synchronized (this) {}
    }
	//类级别
	synchronized static void method3() {}
	//类级别
    void method4() {
        synchronized (SynchronizedDemo.class) {}
    }
    //代码块
    Object object = new Object();
    void method5() {
        synchronized (object) {}
    }
}

上面示例的解决方案,在incr()方法上加锁即可保证结果必然是1000。

public static int count = 0;

public synchronized static void incr() {
    try {
        Thread.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    count++;
}

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 1000; i++) {
        new Thread(() -> Test.incr()).start();
    }
    Thread.sleep(2000);
    System.out.println("结果:" + count);
}

在多个线程争抢资源的时候,会通过监视器抢占锁,成功抢占到的会继续执行,失败的进入同步队列,等待唤醒。
在这里插入图片描述

2.2、volatile(可见性)

volatile可以用来解决可见性和有序性问题。
本质上来说,volatile实际上是通过内存屏障来防止指令重排序以及禁止cpu高速缓存来解决可见性问题。
#Lock指令本意上是禁止高速缓存解决可见性,但实际上是表示一种内存屏障功能,也就是说,针对当前读硬件环境,JMM层面采用Lock指令作为内存屏障来解决可见性问题。
在CPU中,包含三种屏障:

  1. Store Barrier:强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,,并把store缓冲区的数据都刷到cpu缓存
  2. Load Barrier:强制所有在load屏障执行之后的load指令,都在该load屏障指令之后被执行人,并且一直等到load缓冲区被该cpu读完才能执行之后的load指令。
  3. Full Barrier:复合load和store屏障功能

在JVM中,包含四种屏障:

屏障类型指令示例说明
LoadLoad BarriersLoadl; LoadLoad; Load2确保Loadl数据的装载先于Load2及所有后续装载指令的装载
StoreStore BarriersStore1 ; StoreStore; Store2确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore BarriersLoadl; LoadStore; Store2确保Loadl数据装载先于Store2及所有后续的存储指令刷新到内存
StoreLoad BarriersStorel; StoreLoad; Load2确保Storel数据对其他处理器变得可见(指刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

上文示例可以对flag变量增加volatile关键字解决

private volatile static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    new Thread(()->{
        while (flag){
        }
        System.out.println("线程终止");
    }).start();
    Thread.sleep(1000);
    flag = false;
}

2.3、final域

final在java中是一个保留的关键字,可以声明成员变量,方法,类和本地变量,一旦被声明,就无法被改变。
对于final,编译器和处理器要遵循两个重排序规则:

  1. 在构造函数内对一个final的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
  2. 初次读取一个包含final的对象的引用,与随后初次读这个final,这两个操作之间不能重排序。
  3. JMM禁止编译器把final域的写重排序到构造函数之外,
  4. 编译器会在final写之后,构造函数return之前,插入一个StoreStore屏障,这个屏障禁止处理器把final的写重排序到构造函数之外。
  5. 在一个线程中,初次读对象引用与初次读该对象包含的final,JMM禁止处理器重排序这两个操作,编译器会在读final操作的前面插入一个LoadLoad屏障。

在构造方法中,如果存在外部可见的赋值,就会造成构造函数的溢出,final也无法保证有序性。

2.4、Happens-Before模型(可见性模型)

在java中,有一些场景是不需要添加volatile关键字就能保证没有可见性问题的。

2.4.1、程序顺序规则(as-if-serial语义)

1、不能改变程序的执行结果(在单线程环境下,执行的结果不变)
假如在单线程情况下,设置a = 1,b = 1,那么无论a和b如何重排序,都不会互相影响。

2、依赖问题,如果两个指令存在依赖关系,不允许重排序。
假如在单线程情况下,设置a = 1,b = 1,c = a+b,那么c的结果需要依赖a和b的结果,所以禁止重排序。

2.4.2、传递性规则

假如a的结果对b可见,b的结果对c可见,那么a的结果对c也可见。

2.4.3、volatile变量规则

volatile修饰的变量的写操作,一定对happens-before后续对于volatile变量的读操作,即对于volatile修饰的变量,其写操作的结果一定对后续读操作的结果可见。
volatile修饰的写操作与普通写操作是不能进行指令重排序。
在以下代码中,执行结果为i=1。
执行结果过程是a=1先于flag=true,volatile修饰的写操作与普通写操作是不能进行指令重排序
flag的写操作先于if(flag)读操作,volatile修饰的变量的写操作,一定对happens-before后续对于volatile变量的读操作
if(flag)读操作先于i = a写操作,依赖问题,如果两个指令存在依赖关系,不允许重排序。
a=1操作先于i = a,a的结果对b可见,b的结果对c可见,那么a的结果对c也可见。

private int a = 0;
private volatile boolean flag = flag
public void writer() {
	a=1;
	flag=true;
}
public void reader() {
	if(flag) {
		int i = a;
	}
}

2.4.4、监视器锁规则

一个锁释放后的资源对后续所有线程都可见
以下程序,执行结果一定为x=40

int x = 10;
synchronized(this)[
	x = 40
}

2.4.5、start规则

在线程start之前赋予变量的值,在线程中也能获取到。
以下程序执行的结果为x = 30

int x=0;
Thread thread = new Thread(()->{
	System.out.println(x);
})
x = 30;
thread.start();

2.4.5、JOIN规则

在线程内赋予变量的值,在线程join之后也能获取到
以下程序执行的结果为x = 30

int x=0;
Thread thread = new Thread(()->{
	x = 30;	
})
thread.start();
thread.join();
System.out.println(x);

2.5、Atomic原子类

属于JUC工具包,是保证线程安全的一系列原子性类,它包含有四大类型

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用类型
  • 原子更新字段类

上面的加锁示例,可以直接将非原子性操作的++替换为原子性的AtomicInteger

public static AtomicInteger count = new AtomicInteger(0);

public static void incr() {
    try {
        Thread.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    count.incrementAndGet();
}

public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 1000; i++) {
        new Thread(() -> SynchronizedDemo.incr()).start();
    }
    Thread.sleep(2000);
    System.out.println("结果:" + count.get());
}

AtomicInteger能保证原子性操作主要依靠两个机制

  1. Unsafe:unsafe是一个类提供了很多拓展性底层内存操作,
  2. CAS:compareAndSwapInt(object,offset,expect,update),CAS类似于乐观锁的思想,参数Object是调用者本身,offset是内存偏移量,用来的到当前值,except是预期值,update是更新值,只有预期值和当前值一致,才能修改为更新值。这样的操作避免加锁,提高整个的执行性能。
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

2.6、ThreadLcoal

ThreadLocal是一种可以保证单一线程内数据安全的类,在示例代码证中,就算赋予local值增加5,在另一个线程内调用local依然保证取到的初始值是0,多个线程之间不会互相影响。

static ThreadLocal<Integer> local = new ThreadLocal<Integer>() {
    protected Integer initialValue() {
        return 0;
    }
};

public static void main(String[] args) {
    Thread thread1 = new Thread(()->{
        int num = local.get();
        local.set(num+=5);
        System.out.println(Thread.currentThread().getName()+"->"+num);
    },"th1");
    Thread thread2 = new Thread(()->{
        int num = local.get();
        local.set(num+=5);
        System.out.println(Thread.currentThread().getName()+"->"+num);
    },"th2");
    thread1.start();
    thread2.start();
}

2.6.1、get方法

在ThreadLocal的get方法中,先获取当前线程,然后从当前线程中获取ThreadLocalMap ,也就是说,ThreadLocalMap是每一个Thread都拥有的一个Key-Value形式的数据结构。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

ThreadLocalMap的初始化方法中,使用了初始长度为16的数据存储数据,用hash计算下标位置,每个线程可能拥有不止一个ThreadLocal数值,所以使用数组保存

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

2.6.2、setInitialValue方法

在ThreadLocal的setInitialValue()方法中,如果重写initialValue()方法,那么initialValue()方法的返回值就是ThreadLocal的初始值。

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

2.6.3、set方法

在ThreadLocal的set()方法中,会将弱引用类型Entry被内存回收的值清除防止内存泄露。对于Hash冲突,ThreadLocal使用线性探索,也叫开放寻址法,就是当某个Hash值产生冲突后,就查看下一个位置是否为空,若不为空就继续查找,直到查到空地址,直接插入。当hash值超过16个,Entry数组将会扩容。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

private void set(ThreadLocal<?> key, Object value) {

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值