JavaEE——线程的状态与线程安全

线程的状态

线程的状态一般有五种:创建、就绪、运行、阻塞、终止。而Java中的线程状态分为:NEW(创建了新线程,但还没有start),RUNNABLE(包括就绪态和运行态),BLOCKED、WAITING、TIMED_WAITING(线程处于阻塞状态)以及TERMINATED(线程终止)。

即任意一个线程要经历“创建->运行->终止”这一过程,在运行过程中,有可能遇到阻塞状态(当前线程停止运行,等待某个条件成熟继续执行)。

当前线程的状态可以通过getState方法获取。

NEW、RUNNABLE、TERMINATED这三种状态比较好理解:

Thread t1 = new Thread(() -> {
    try {
        Thread.sleep(1000);
        System.out.println("线程运行中:" + Thread.currentThread().getState());
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
});
System.out.println("线程开始前:" + t1.getState());
t1.start();
t1.join();
try {
    Thread.sleep(1000);
    System.out.println("线程结束:" + t1.getState());
} catch (InterruptedException e) {
    e.printStackTrace();
}

容易得到三种状态:

而对于阻塞状态,Java中共有三种:BLOCKED、WAITING、TIMED_WAITING,虽然三种状态都表示阻塞,却有很大的不同。

BLOCKED是指线程进行到某行加锁的代码时,必须停下来,等待加锁区域执行完毕并释放锁后,该线程才能竞争锁,竞争到锁就继续执行,否则继续等待直到下次释放锁。而在等待时,线程就处于BLOCKED状态。

WAITING指的是由于wait方法造成的阻塞等待,wait方法是Object类的一个方法,它的作用是使当前线程进入阻塞等待并释放锁,等待时机成熟(其他线程调用notify方法)后解除阻塞状态。

由于wait会释放锁,因此必须在加锁区域才可使用先竞争到锁才能释放锁)。

wait方法中可以设置参数,表示等待的最大时长。超出等待时长后自动解除阻塞状态。

TIMED_WAITING指的是当前线程处于sleep时的状态。超过等待时长后解除阻塞状态,转为运行状态。

线程安全问题

上文提到了加锁操作,那么什么是加锁,又为什么要加锁呢?

假设我们需要加和10000次,我们可以通过两个线程并发的方式来实现,这样就可以大大的提高效率:

class Counter {
    public int num = 0;
    public void add() {
        num++;
    }
}
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 5000; i++) {
        counter.add();
    }
});
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 5000; i++) {
        counter.add();
    }
});
t1.start();
t2.start();
try {
    Thread.sleep(3000);//确保两个线程都执行完
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println("加和次数为:" + counter.num);

然而实际情况并不能加和10000次:

造成这个结果的原因在于多个线程修改了同一份数据,而修改操作并不是原子的,实际上大致分为三步:

load->add->sava。那么在实际运行过程中就可能出现各种的意外:

其中一种可能的执行情况如下:

也就是说t1线程加载了当前num的值(假如为1),这时t2线程也加载,并进行add和save,此时虽然num为2,但是t1线程加载的值为1,因此t1线程执行完得到的结果也为2:

由此可推出,这样的加和操作可以得到1~10000之间的任何结果。像这样的线程就是不安全的。原因就在于add操作不是原子的(不可分割的)。那么我们要做的就是把它变为原子的——也就是加锁:

只需要把add操作进行加锁即可:

public void add() {
    synchronized (this) {//参数部分为锁对象,为Object类,只起到标识作用。
        num++;
    }
}

或者直接把关键字synchronized加到public之前(即函数加锁):

synchronized public void add() {
        num++;
}

如果只进行读操作,不会涉及到线程安全的问题。只有多个线程修改同一个变量或方法时,才涉及到线程安全问题。

解决线程安全问题,除了加锁外,还可以用volatile关键字修饰来解决。编译器往往会自行判断我们的代码并进行适当的优化。

比如在某一个线程中只是频繁读取某个数据却不进行相关操作,编译器会优化为只读取一次,后续不再进行读取操作。如果是单线程,这么做一定没错,但是多线程可就不一定了,也许修改操作在其他的线程进行,那么就一定会出现BUG。

除了读取方面的优化,编译器往往还会进行指令的重排序,这种重排序在单线程里从结果上来看没有问题,却能够提高效率。然而在多线程中这种顺序一旦改变有可能造成各种各样的问题:

比如说要新建一个对象,分为三步:

  1. 申请内存空间

  1. 构造方法(初始化内存)

  1. 获取内存引用

第一步肯定要申请内存空间,但2和3谁先谁后区别不大。想象一下买房子,申请内存空间就是把房子买下来,构造方法就是装修,获取内存引用就是拿到钥匙。显然装修->拿到钥匙还是拿到钥匙->装修都可以。然而在单线程下可以,不代表多线程也可以:

假设在t1线程新建对象时先获取了内存引用(此时对象不为空)但是还没有构造方法,这时t2线程就使用了该对象,显然就会出现问题。

对于这两种由编译器的优化所引起的线程安全问题,可以通过加关键字volatile解决。

volatile的作用是强制读取内存,保证内存的可见性,同时禁止指令的重排序。

但是volatile不能保证原子性,因此和synchronized的作用不冲突。


多线程操作显著提高了代码的效率,却带来了很多的线程安全问题,为了解决这些安全问题,就不得不牺牲一定的效率——效率和准确往往不能兼顾,不过即使牺牲了一些效率来换取线程的安全,多线程的效率还是高于单线程的。

猜你喜欢

转载自blog.csdn.net/weixin_71020872/article/details/129657665