什么是线程安全
《Java Concurrency In Practice》的作者 Brian Goetz 对线程安全是这样理解的,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。
通俗来讲,就是在多线程并发的情况下,每个线程的执行结果始终都是预期的结果。那么这个线程就是线程安全的。
出现线程安全的三种情况
在理解了上面的概念后,如果平时开发的时候就会发现,我们会经常遇到线程不安全的情况,大概的罗列了以下三种
- 运行结果错误
- 发布和初始化导致线程安全问题
- 活跃性问题
运行结果错误
首先我们先看下面一段代码
public class WrongResult {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count++;
}
}
};
Thread t0 = new Thread(runnable);
Thread t1 = new Thread(runnable);
t0.start();
t1.start();
t0.join();
t1.join();
System.out.println(count);
}
}
上面这段代码的逻辑,就是两个线程对count进行自加一,每个线程都会循环自加1000次。那么预期的结果应该是2000。单其实执行的结果却没有2000,会始终比2000少,这究竟是什么原因导致这个种情况的发生呢。
这里就需要理解我们计算机CUP调度的分配了。在CUP的分配规则里面,是以时间为单位给每个线程去分配的,如果当前线程的所分配的时间执行完,就会切给另外一个线程去执行,这样不好的地方就是,他没有办法保证i + +的原子性,我们可以先看下面这张图。
通过上面的图我们可以了解到,i + +在cup执行的步骤其实分为三步
- 第一步是读取i的值
- 第二步是执行加一操作
- 第三步就是保存结果的值
这样我们就可以仔细的思考一下,如果CUP给线程一分配的时间只足够他执行到第二步操作,之后就被切到了线程二,那么这时就会导致线程二没有获取到最新的值,因为线程一还没有执行到第三步去保存结果就被CPU给切掉了。那么这个时候线程二再去自增计算出来的值,就会和线程一获得的结果一样的。自然我们拿到的结果就会比预期的结果少了很多。像这种情况也就是最典型的线程安全问题。
发布和初始化导致线程安全问题
这种就比较好理解,比如在项目启动的时候,去获取一段初始数据,但是这个数据需要通过线程异步的初始化才会有。但是线程初始化数据需要时间,如果程序在线程还没有初始化完成数据后就去获取数据,这个时候就会导致线程安全问题。可以看下面这段代码来理解。
public class WrongInit {
private List<String> info;
public WrongInit() {
info = new ArrayList<>();
Thread thread = new Thread(()->{
info.add("数据开始初始化");
try {
info.add("数据初始化中....");
//模拟数据初始化需要的时间
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
info.add("数据初始化完成!");
});
thread.start();
}
public static void main(String[] args) throws InterruptedException {
WrongInit init = new WrongInit();
init.info.forEach(System.out::println);
}
}
上面的代码中,为了能模拟出数据没有完全初始化的情况,在线程中休眠了1秒中,其实数据应该是要打印出【数据初始化完成!】然而结果去只打印到【数据初始化中....】,其实在这里还会有另外一种情况,如果创建线程的时间大于程序调用的时间,可能会直接报空指针异常。这种也是线程不安全的情况。
活跃性问题
线程的活跃性问题其实可以分为三种,分别为死锁,活锁和饥饿。
这三种都有个一个共同的特性,就是他们会让线程卡住,死活也得不到运行结果,这种情况其实是最线程安全中最复杂的也是最严重的,如果线程卡住的太多,不仅会占用服务的资源,甚至还会导致服务假死或者宕机。
死锁
死锁比较常见,就是两个线程互相等单对方的资源,单同时又互不相让,都想自己先执行。可以看下面这段代码
public class ThreadDeadMain {
private static Object o1 = new Object();
private static Object o2 = new Object();
public static void main(String[] args) {
Thread thread01 = new Thread(()->{
synchronized (o1){
System.out.println(Thread.currentThread().getName()+"获取到o1的锁了");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2){
System.out.println(Thread.currentThread().getName()+"获取到o2的锁了");
}
}
});
Thread thread02 = new Thread(()->{
synchronized (o2){
System.out.println(Thread.currentThread().getName()+"获取到o2的锁了");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1){
System.out.println(Thread.currentThread().getName()+"获取到o1的锁了");
}
}
});
thread01.start();
thread02.start();
}
}
上面这段代码中启动了两个线程,每个线程中有两个锁,线程1启动时会先获取o1的锁,然后再去获取o2的锁,之后才会执行完毕最后释放o2和o1的锁,线程2相对于线程1的逻辑则相反,显示获取o2的锁,然后再去获取o1的锁,最后执行完毕释放o2的锁再去释放o1的锁。为了让两个线程能发生死锁的情况,我在两个线程都获取第一个锁的时候让线程休眠的一秒种,这样等到两个线程同时去获取第二个锁的时候,就会发现,线程1的第二个锁的o2在线程2的第一个锁总没有释放,然而线程2的第二个锁o1又被线程1占着,这样就会发生两者互不相让,又同时占领着锁。导致程序一直卡着。
活锁
活锁相对于死锁有种相反的意思,死锁是卡着资源,然后活锁不一样,他不占用锁的资源,但是他会一直运行着,不过他会一直循环运行,但是一直没有遇到正确的结果,导致线程一直在运行。但是他不会像死锁一样卡着不运行。
可以看到下面这段代码
public class ThreadLiveMain implements Runnable {
private int num;
public ThreadLiveMain(int num) {
this.num = num;
}
@Override
public void run() {
System.out.println("中奖数字:"+num);
int i = 0;
do {
//随机获取1-10的数字
Random random = new Random();
i = random.nextInt(10)+1;
System.out.println("随机数:"+i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}while (num!=i);
System.out.println("抽中啦!");
}
public static void main(String[] args) {
ThreadLiveMain main = new ThreadLiveMain(11);
Thread thread = new Thread(main);
thread.start();
}
}
这是一个类似于抽奖的小程序,线程里面会随机出1-10的数字来判断自己是否中奖,如果我们给中奖数字在1-10内,这个时候程序就会得到正常的结果,因为他在随机数范围内。但是如果我们给了一个11,这个时候就超出随机数的范围了。线程会一直的去循环判断,但是又遇不到正确的随机数字,这样线程就不会停止下来,这种情况就称之为活锁。
饥饿
饥饿就比较有趣了,他就真的是因为饥饿所以才导致线程拿不到结果,Java的线程有优先级的概念,有1-10的优先级划分,如果一个线程的等级被设置到最低的1,那么这个线程可能永远也拿不到线程的资源,线程吃不到饭,自然也没力气干活,没力气干活那也就拿不到结果。还有一种情况就是某个线程持有某个文件的锁,如果其他线程想有修改文件就需要先获得这个文件的锁,那这个修改文件的线程就会陷入饥饿,没法在继续运行。