线程安全问题
看如下代码:
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++;
}
代码中使用了一个线程池,指定了4个线程,随后让这些线程一共执行1000次的 globalI + 1操作,
最终得到的结果应该是1000,然而我们最终得到的结果却是997,如果将线程池的数量由4改为1,最终运算的结果是正确的,
这就反应出了,在多线程的情况下对同一个变量进行操作,最终的结果不一定是正确的,这称为线程不安全,
如何衡量一个程序是否是线程安全的呢? 就是在单线程和多线程的环境下,得到的结果都是一样的
,这叫做线程安全的程序。
上面的globalI++;
这行代码,看似是一行代码,其实程序执行的时候,拆成了三部去操作:
-
int localVariable = globalI; 将主内存中的globalI读取一份到本地副本中来
-
localVariable = localVariable + 1; 将本地副本中的值+1
-
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);
}
如何保证线程安全
- 不可变对象
将一个对象设置为不可修改,即可保证这个对象在多线程环境下也是正确的,知名的不可变类有String
,对字符串做的所有修改,都会产生一个新的字符串对象,而不是对原本的对象做修改.
- 使用锁
使用java自带的synchronized
、Lock
等手段,保证安全.
- 并发工具包(底层实现通常是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注释中没有声明这个类是线程安全的,基本上这个类都是不安全的。
- HashMap
多线程情况下导致死循环。
- java.util.Date
public void setTime(long time) {
fastTime = time;
cdate = null;
}
Date类中具有一个setTime方法,因为这个类允许被改变自身的状态,同时没有其他线程同步的保障,所以是一个非线程安全的类。
- SimpleDateFormat
该类也可以改变正在操作的时间,因此多线程同时操作时,会发生时间转换结果不对的问题。