Java多线程(9)-死锁问题、线程安全容器与CAS

线程安全问题

看如下代码:

private static int globalI = 0;

public static void main(String[] args) throws Exception {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
    for (int i = 0; i < 1000; i++) {
        executorService.submit(() -> {
            inc();
        });
    }
    Thread.sleep(5000);
    System.out.println(globalI);
}

private static void inc() {
    globalI++;
}

image.png

代码中使用了一个线程池,指定了4个线程,随后让这些线程一共执行1000次的 globalI + 1操作,

最终得到的结果应该是1000,然而我们最终得到的结果却是997,如果将线程池的数量由4改为1,最终运算的结果是正确的,

这就反应出了,在多线程的情况下对同一个变量进行操作,最终的结果不一定是正确的,这称为线程不安全,

如何衡量一个程序是否是线程安全的呢? 就是在单线程和多线程的环境下,得到的结果都是一样的,这叫做线程安全的程序。

上面的globalI++;这行代码,看似是一行代码,其实程序执行的时候,拆成了三部去操作:

  1. int localVariable = globalI; 将主内存中的globalI读取一份到本地副本中来

  2. localVariable = localVariable + 1; 将本地副本中的值+1

  3. globalI = localVariable; 将本地副本写会到主内存当中

由此可以看到,这个++操作其实并不是一步完成的,也就是非原子性,当我们对本地的副本+1,并写会主存的时候,

很有可能其他的线程也在进行这么一步操作,如果a拿到的是10,b拿到的也是10,分别在自己的本地中+1,再写回主内存,这时候主内存是11,但其实我们希望两次+1,最终写回主内存的是12,

导致这个问题的发生原因,称之为非原子性操作,因为x++,并不是一步达成的,中间分为了几步,而这过程中可能有其他人也在做这个事情,所以为非原子,

非原子操作符合多线程竞争条件之一:read-modify-write(读、修改、写), 符合竞争条件的,都是属于线程非安全的代码,需要通过其他手段来保证安全,

在多线程的情况下,原本单线程开发测试环境下不会出现问题的代码,在线上复杂的多线程访问后,会发生很多意向不到的情况,所以我们在开发的过程中要考虑自己的代码,是否在多线程环境下依然靠谱,

接下来看看竞争条件之二: check-then-act(检查再运行), 看下方代码:

private static SingletonObject INSTANCE = null;

public static SingletonObject getInstance() {
    if (INSTANCE == null) {
        INSTANCE = new SingletonObject();
    }
    return INSTANCE;
}

private SingletonObject() {

}

首先我们希望SingletonObject类在程序运行过程全局中只能存在一个实例对象,所以我们将构造函数改为了私有的, 只能通过getInstance方法获取一个实例对象,

这种写法也是非安全的,因为很有可能有两个线程同时调用了getInstance方法,同时判断INSTANCE 为null,然后new了两个实例出来,虽然最终还是只有一个才能赋值上去,但这不符合我们的预期,我们的预期是只能new一次,

下面看一个正确的写法:

private static SingletonObject INSTANCE = new SingletonObject();

public static SingletonObject getInstance() {
    return INSTANCE;
}

private SingletonObject() {

}

我们通过jvm的类加载机制,保证这个对象只有在类加载的时候new一次,其余的时候因为构造函数是私有的,都不可以被其他的地方new出一个新的实例,

或者使用enum,jvm会保证一个枚举实例,全局只会存在一个:

public enum SingletonObject {

    INSTANCE;

}

下面再看一个符合check-then-act条件的代码:

private Map<String,Object> values = new ConcurrentHashMap<>();

public void put(String key, Object value) {
    if (!values.containsKey(key)) {
        values.put(key, value);
    }
}

可以看到,虽然我们使用的是ConcurrentHashMap, 线程安全的HashMap,但是由于我们还是用if先判断了是否存在某个值,然后再去做操作,

这个判断的同时,依然可能有别的线程同时在做相同的操作,所以这依然是线程不安全的,

我们可以使用ConcurrentHashMap的原子方法putIfAbsent,即可保证线程安全:

private Map<String,Object> values = new ConcurrentHashMap<>();

public void put(String key, Object value) {
    values.putIfAbsent(key, value);
}
如何保证线程安全
  1. 不可变对象

将一个对象设置为不可修改,即可保证这个对象在多线程环境下也是正确的,知名的不可变类有String,对字符串做的所有修改,都会产生一个新的字符串对象,而不是对原本的对象做修改.

  1. 使用锁

使用java自带的synchronizedLock等手段,保证安全.

  1. 并发工具包(底层实现通常是CAS)

int -> AtomicInteger

long -> AtomicLong

HashMap -> ConcurrentHashMap

ArrayList -> CopyOnWriteArrayList

TreeMap -> ConcurrentSkipListMap

[](数组) -> AtomicLongArray

Object -> AtomicReference

他们的实现基本都是CAS Compare And Swap, 意为先比较,再替换,

AtomicInteger i = new AtomicInteger();
System.out.println(i.incrementAndGet());
System.out.println(i.incrementAndGet());

例如,先读到i的值是1,那这时候+1, 就应当写入 2 回去,写入的时候利用汇编指令,先对比主内存的值是不是1,如果是1,说明和我们刚刚拿到的旧值是一样的,

此时可以把2写入进去,如果不是,就放弃这次操作,什么也不做,

incrementAndGet使用到了CAS+自旋,即对比是否可以成功写入,如果不可以则重新尝试,直到写入成功为止。

常见的线程不安全类

如果类的doc注释中没有声明这个类是线程安全的,基本上这个类都是不安全的。

  1. HashMap

多线程情况下导致死循环。

  1. java.util.Date
public void setTime(long time) {
    fastTime = time;
    cdate = null;
}

Date类中具有一个setTime方法,因为这个类允许被改变自身的状态,同时没有其他线程同步的保障,所以是一个非线程安全的类。

  1. SimpleDateFormat

该类也可以改变正在操作的时间,因此多线程同时操作时,会发生时间转换结果不对的问题。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值