线程的状态与安全问题

1. 线程的6种状态

操作系统中的进程/线程有五大状态:创建态、运行态、就绪态、阻塞态、终止态。

在Java中对线程的状态进行了更加详细的区分,分别为以下几个状态:

  • NEW:创建了Thread对象,还没有调用start方法
  • RUNNABLE:就绪状态,这里和系统中就绪状态的又不太一样分为正在CPU运行的和已经准备好上CPU运行的
  • BLOCKED:阻塞状态,等待锁(后面详细说)
  • WAITING:阻塞状态,线程中调用了wait()方法(后面详细说)
  • TIMED_WAITING:阻塞状态,通过sleep()进入的阻塞
  • TERMINATED:系统里的线程已经执行完毕,销毁了,但是Thread对象还在。

图示如下:主干道为 NEW->RUNNABLE->TERMINATED,但是在RUNNABLE阶段可能会经历一些分支(阻塞)

image.png

2. 线程安全问题

线程安全问题是多线程编程中最重要,也是最困难的问题,这里演示一个经典的案例。

2.1 引入案例

创建两个线程,让这两个线程对同一个变量自增50000次,最终预期自增100000次

class Count {
    public int sum;
    public void increase() {
        sum++;
    }
}
public class Demo12 {
    public static void main(String[] args) {
        Count count = new Count();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.increase();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(count.sum);
    }
}

运行多次结果如下:这其实就是线程安全问题

50161
64987
56213
67145
48267

为什么会出现这种现象呢?

其实像sum++这样一句代码,对应三个机器指令:

  1. load:把内存中的值读到CPU的寄存器中
  2. add:在CPU寄存器中,完成加法运算
  3. save:把寄存器的值存到内存里

它们在多核CPU中并行执行一次load+add+save指令的时候,可能会出现下面的情况:

image.png

CPU与内存的初始状态如下:

image.png
  1. 当线程1执行load指令,将内存中的值读到cpu的寄存器中:
  1. 然后线程2执行load指令,将内存中的值读到cpu的寄存器中:
  1. 线程1执行add,在cpu中进行+1的操作:
  1. 线程1执行save,将cpu中的值存入内存中:
image.png
  1. 线程2执行add,在cpu中进行+1的操作:
image.png
  1. 线程2执行save,将cpu中的值存入内存中:
image.png

以此类推,在并行执行的情况下,两个线程除了串性执行完3个语句的情况,都会导致其中1个线程的+1操作失效

甚至在并发的情况下,线程1在执行load指令后刚好执行完一个时间片,然后线程2在CPU上连续执行了多次load+add+save的操作,此时线程1继续被调度到CPU上执行add指令和save操作,并且保存到内存中,导致线程2的多次+1的结果被覆盖掉。

2.2 线程不安全的原因

1. 操作系统的抢占性执行/随机调度

2. 多个线程修改同一个变量

3. 修改操作非原子性

原子性的解释:CPU是以一个机器指令为单位来执行的,因此一个机器指令就是一个原子的操作,由于我们前面的案例,一次a++的代码对应了三个机器指令,因此该操作是非原子的。

4. 内存可见性问题

内存可见性的解释:如果是正常情况下,比如线程1循环执行while(a>0)这句代码,该代码涉及LOAD(将变量从内存加载到CPU的寄存器上)+CMP(在CPU中比较寄存器的值)两个机器指令。

在引入优化之前,线程1执行的过程中,线程2突然进行写操作都正常。

因为在线程2写操作结束之后,线程1下一次读的时候就能立刻读到内存的变化。

image.png

但是程序在运行的过程中,在多次读到相同的值时可能会涉及到一个“优化”操作,该优化操作可能来自编译器javac、JVM、操作系统。由于LOAD指令涉及读内存的操作(该操作较慢),为了提高性能,JVM在多次读到相同的值后,就直接复用CPU寄存器中的值,也就优化成下面这样:

image.png

此时如果线程2突然出现写操作,线程1在下一次读的时候是感知不到的,这就是内存可见性问题

所谓优化,必须在程序不出BUG的前提下进行,上述场景的优化在单线程中没有问题,但是在多线程的环境下,进行的优化可能就会出现误判

5. 指令重排序

指令重排序同样也是优化搞的鬼,指令重排序指的是在逻辑不变的情况下,通过指令的重排序,来提高程序运行的速度。

但是在多线程环境下,保证逻辑不变,就不容易了。

比如Test t = new Test()这样的代码,可以分为三步:

  1. 开辟内存空间
  2. 在内存中创建对象
  3. 将对象引用赋值给t

在单线程的环境下,2、3步骤是可以重排序的,假设步骤2和步骤3重排序为了3、2。

在多线程的环境下,线程1执行Test t = new Test()的代码,线程2调用t的方法,线程2使用t对象的时候就可能会出现引用t不为null但是引用指向的对象为null的内存泄漏问题

2.3 线程不安全的解决方案

  • 问题一:系统的抢占性执行/随机调度,是我们无能为力的
  • 问题二:多个线程同时修改一个变量(部分规避):有些情况就是需要涉及多个线程修改同一个变量。
  • 问题三:修改的操作不是原子性的:程序员可通过加锁操作避免
  • 问题四:内存可见性问题:volatile关键字
  • 问题五:指令重排序:volatile关键字

对于上面的线程不安全问题,我们发现问题三到问题五是可以让程序员通过一些操作来避免的,接下来我就详细介绍下加锁操作以及volatile关键字的使用。

2.3.1 解决原子性问题 —— synchronized

加锁的目的就是把一系列机器指令变成原子的,加锁是怎么把这样一个代码块变成原子的呢?

比如当线程A执行到某个需要原子操作的代码块后,如果该代码块没有加锁,会对这个代码块进行加锁。

线程B再想调用这个代码块的时候,会检查锁的状态,如果此时有锁,那么该线程就会被阻塞,由RUNNABLE状态变为BLOCKED状态,不再参与调度,指导这个锁被释放,该线程才由BLOCKED状态变为RUNNABLE状态,参与操作系统的调度。

image.png

这里我将使用synchronized关键字来解决前面提到的案例所引发的线程安全问题。

1)对this进行加锁

使用synchronize修饰代码块的时候,必须传一个锁对象,在Java中的任何一个对象都可以作为锁对象。

public void increase() {
    synchronized (this) {
        sum++;
    }
}

锁对象的解释: 观察上面代码,this其实就是锁对象,加锁操作是针对一个锁对象来进行的。我们可以把这个锁对象想象成一个门,当线程执行到该代码块的时候,如果这个门没有锁,就给这个门上锁。

在这里我们为什么选择使用this充当锁对象呢?

class Count {
    public int sum;
    public void increase() {
        synchronized (this) {
            sum++;
        }
    }
}

观察上面的Count对象,如果想要让多个线程修改同一个变量,他们就必须使用同一个Count实例,像下面这样:

Count count = new Count();
Thread t1 = new Thread(() -> {
    for (int i = 0; i < 50000; i++) {
        count.increase();
    }
});
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 50000; i++) {
        count.increase();
    }
});
t1.start();
t2.start();

因此t1t2访问的是同一个门,就只要对这个门上锁就好啦。

这样处理的话,如果现在有一个线程t3还想再对另一个变量进行50000次的自增,就需要再去new一个Count实例。

由于修改的是不同的变量,不存在线程安全问题,因此不需要对t3的自增操作进行加锁,由于此时的this是一个新的门,就算t1或者t2对他们的门进行加锁,t3还是可以进自己的门去执行自增的代码。

public class Demo12 {
    public static void main(String[] args) {
        Count count1 = new Count();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count1.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count1.increase();
            }
        });

        Count count2 = new Count();
        Thread t3 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count2.increase();
            }
        });

        t1.start();
        t2.start();
        t3.start();

        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(count1.sum);
        System.out.println(count2.sum);
    }
}

同理,我们也可以自定义一个锁对象(必须是成员变量),才能达到一样的效果:

class Count {
    private final Object locker = new Object();
    public int sum;
    public void increase() {
        synchronized (locker) {
            sum++;
        }
    }
}

像这样把整个方法中的代码块都进行加锁的情况,相当于直接在普通方法前面添加synchronized关键字来修饰方法:

class Count {
    public int sum;
    synchronized public void increase() {
        sum++;
    }
}

2)对类对象加锁

将案例中的代码修改为对静态变量进行自增:

class StaticCount {
    public static int sum;
    public static void increase() {
        sum++;
    }
}
public class Demo13 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                StaticCount.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                StaticCount.increase();
            }
        });
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println(StaticCount.sum);
    }
}

由于静态变量是全局唯一的,因此所有成员锁对象都不适用了,上面的场景就得对类对象进行加锁了:

class StaticCount {
    public static int sum;
    public static void increase() {
        synchronized (StaticCount.class) {
            sum++;
        }
    }
}

像这样把整个方法中的代码块都进行加锁的情况,相当于直接在静态方法前面添加synchronized关键字来修饰方法:

class StaticCount {
    public static int sum;
    synchronized public static void increase() {
        sum++;
    }
}

2.3.2 解决优化问题 —— volatile

2.3.2.1 保证内存可见性

这里我将通过代码演示内存可见性问题

创建一个t1线程,如果Counter.flag的值为0,就一直循环运行下去

public class Demo14 {
    static class Counter {
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while (counter.flag==0) {
                
            }
            System.out.println("循环结束!");
        });
    }

然后在创建一个线程t2,在休眠3s后将Counter.flag的值改为1

        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            counter.flag = 1;
            System.out.println("已将Counter.flag的值改为1~");
        });

        t1.start();
        t2.start();
    }
}

预期的结果:将Counter.flag改为1后,t1就在下一次读取flag发现不等于0后,就退出循环。

结果是否定的:产生这种BUG的原因就是内存可见性问题

image.png

该问题的解决方式,就是在变量前添加一个volatile关键字:

static class Counter {
    volatile public int flag = 0;
}

运行结果:

已将Counter.flag的值改为1~
循环结束!

Process finished with exit code 0

相当于是给这个变量加上了内存屏障(特殊的二进制指令),JVM在读取这个变量的时候,因为内存屏障的存在,就知道要每次都重新读取,而不是草率的优化。

还有个有意思的操作,下面的代码中,我把volatile给删掉,并且在t1线程的循环语句中添加了一个Thread.sleep(100);

public class Demo14 {
    static class Counter {
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        //创建一个t1线程,如果flag的值为0,就一直循环运行下去
        Thread t1 = new Thread(() -> {
            while (counter.flag==0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println("循环结束!");
        });

此时由于在代码中添加了Thread.sleep(100),让CPU读取flag的频率大大降低,就不触发 优化了,也就没有内存可见性问题了:

已将Counter.flag的值改为1~
循环结束!

Process finished with exit code 0

但是由于咱也不好确定啥时候优化,啥时候不优化,因此我们还是在必要时给变量添加volatile关键字。

2.3.2.2 解决指令重排序问题

出了保证内存可见性,volatile修饰的变量还可以避免指令重排序问题。

2.4 线程安全的集合类

在Java标准库中,大部分集合类,都是线程不安全的,如下:

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还是有一些类是线程安全的,如下:

  • Vector(不推荐使用)
  • HashTable(不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

VectorHashTable把所有关键方法都添加了synchronized,由于加锁后就容易产生阻塞等待,加锁会牺牲很大的运行速度,因此不推荐使用前两种线程安全的集合类。

  • 10
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

干脆面la

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值