Java线程安全问题,你都了解哪些?

TodoCoder大约 8 分钟Java并发Java并发

  之前我们聊到多线程的实现,但是线程开发不只是实现一个线程这么简单,线程的实现只是多线程开发的第一步,实现线程后我们还需要保证线程运行的安全性,高效性。不安全的线程实现会导致程序运行结果错误,也可能会导致程序永久性卡死也就是死锁。为了避免这些问题,我们就需要了解什么是线程安全。

什么是线程安全?

  线程安全经常在工作中被提到,比如:你的对象是不安全的,你的线程运行结果不对,虽然线程安全经常被提到,但我们可能对线程安全并没有一个明确的定义。那么到底什么是线程安全呢?

《Java Concurrency In Practice》的作者 Brian Goetz 对线程安全是这样理解的,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。

Brian Goetz想表达的意思是,如果这个对象是安全的,那么对于使用者而言,在使用的时候不需要考虑多个线程同时写入或读写安全的问题,也不需要额外加锁,那么这个对象我们才能称之为线程安全的对象。

线程安全问题有哪些?

在我们实际开发过程中经常会遇到线程不安全的情况,这里我们介绍3种典型的线程安全问题。

  1. 启动线程时导致线程安全问题。
  2. 多线程运行结果错误。
  3. 活跃性问题。

启动线程时导致线程安全问题

  线程创建和启动时导致的线程安全问题,我们创建对象并进行发布和初始化供其他类或对象使用是常见的操作,但如果我们操作的时间或地点不对,就可能导致线程安全问题。如代码所示:

public class ThreadSafe {
  	private static List<String> persions;
    public static void main(String[] args) {
        new Thread(() -> {
          	persons = new ArrayList<>();
            persons.add("TodoCoder1");
            persons.add("TodoCoder2");
            persons.add("TodoCoder3");
        }).start();
        System.out.println(persons.size());
        System.out.println(persons.get(1));
    }
}

  我们创建一个共享变量List, main函数中创建并启动一个线程,在线程中给List初始化并添加元素,在主线程中打印结果。试想这个时候程序会出现什么情况?实际上会发生空指针异常或数组越界。

这又是为什么呢?因为 persons 这个成员变量在线程中初始化并添加数据,而线程的启动需要一定的时间,但是我们的 main 函数并没有进行等待就直接获取数据,导致 persons还没有初始化完成或者没有添加完数据,这就是在错误的时间或地点发布或初始化造成的线程安全问题。

多线程运行结果错误

首先,来看多线程同时操作一个变量导致的运行结果错误。

public class ThreadSafe {
    volatile static int i = 0;
    public static void main(String[] args) throws InterruptedException {
				Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int j = 0; j < 10000; j++) {
                    i++;
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        thread1.start();
        Thread thread2 = new Thread(runnable);
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(i);
    }
}

如代码所示,首先定义了一个 int 类型的静态变量 i,然后启动两个线程,分别对变量 i 进行 10000 次 i++ 操作。理论上得到的结果应该是 20000,但实际结果却远小于理论结果,比如可能是15796,也可能是16923,每次的结果都还不一样,这是为什么呢?

这是因为多线程下,线程的执行和调度是由CPU决定的,而CPU 的调度线程是以时间片为单位进行分配的,每个线程都可以得到一定量的时间片。但如果线程拥有的时间片耗尽,它将会被暂停执行并让出 CPU 资源给其他线程,这样就有可能发生线程安全问题。而对于i++操作,看上去是一行代码,实际上它不是一个原子性的操作,它的执行步骤分三步如下:

  1. 第一个步骤是读取,即:i=1;
  2. 第二个步骤是增加,即:i+1;
  3. 第三个步骤是保存,即:i=2;

如果线程1先那到i=1, 然后执行 i+1,结果还没来得急保存下来, 这个时候线程1被切到线程2执行,线程2取的值依然是 i=1, 那么线程2执行完 i+1, i=2后 i的结果是2,线程1的结果也是2,最终的结果就是2了,而不是我们预期的 i=3 , 这就发生了线程安全问题了。

对于这个情况的结决方案,可以用原子类来处理,这里不展开讲了。

活跃性问题

  什么是活跃性问题呢?字面上我们可以把它解释成线程不活跃了,也就是程序始终得不到运行的最终结果,相比于前面两种线程安全问题带来的数据错误或报错,活跃性问题带来的后果可能更严重,那么都哪些问题能让线程一直得不到结果呢?总结为以下三种:

  1. 死锁
  2. 活锁
  3. 饥饿

死锁

  死锁是最常见的活跃性问题,死锁是指两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行,然后就一直等待,如下:

public class ThreadSafe {
    private static Object o1 = new Object();
    private static Object o2 = new Object();
    public static void thread1() {
        synchronized (o1) {
            //拿到o1锁
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
          	//等待 thread2释放 o2锁
            synchronized (o2) {
                System.out.println("线程1成功拿到两把锁");
            }
        }
    }
    public static void thread2() {
        synchronized (o2) {
            //拿到o2锁
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
          	//等待 thread1释放 o1锁
            synchronized (o1) {
                System.out.println("线程2成功拿到两把锁");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(ThreadSafe::thread1);
        Thread thread2 = new Thread(ThreadSafe::thread2);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

首先代码中创建两个对象o1,o2作为 synchronized 锁的对象,线程1 先获取到o1对象锁然后执行逻辑获者sleep 1秒,同时线程2获取到o2对象锁也sleep1秒,休息完后线程 1 想获取 o2 锁,线程 2 想获取 o1 锁,但锁都被对方持有,这时就发生了死锁,就这样一直等待对方先释放资源,导致程序得不到任何结果也不能停止运行。

活锁

  第二种活跃性问题是活锁,活锁与死锁非常相似,也是程序一直等不到结果,但对比于死锁,活锁是活的,什么意思呢?因为正在运行的线程并没有阻塞,它始终在运行中,却一直得不到结果。

比如:在消息队列中,一般处理的时候,执行报错时,由于队列的重试机制会重新把消息放在队列头进行优先重试处理,但这个消息本身无论被执行多少次,都无法被正确处量,周而复始,最络导致线程一直忙碌,但程序始终得不到结果,这就是活锁问题。

饥饿

  第三个典型的活跃性问题是饥饿,饥饿是指线程需要某些资源时始终得不到,尤其是CPU资源,得不到CPU执行权就会导致线程一直不能运行而产生问题。

比如:在用线程池的时候,如果设置的核心线程数太少,在执行的线程遇到大文件读取的IO阻塞,或者数据库插入死锁,亦或者HTTP请求网络阻塞且没有设置超时时间这几个情况都会导致其它线程得不到执行资源,处于长时间的饥饿状态。

以上就是一些线程安全问题,我们在开发中要多多考虑这些,这些场景在高并发中尤为常见,在面试中能考虑这么多的也是个加分项。