Java线程安全

一、线程安全的概念

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。

二、线程不安全的原因

首先我们来看一个线程不安全的实例:

两个线程对一个数进行自增5w次,假如这个数开始是0,那么两个线程自增过后,预期结果应该是10w

public class Demo1 {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker){
                for (int i = 0; i < 5_0000; i++) {
                    count++;
                }
            }

        });
        Thread t2 = new Thread(()->{
            synchronized (locker){
                for (int i = 0; i < 5_0000; i++) {
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
        //打印count值的时候, 需要等待t1 和t2 执行完
        t1.join();
        t2.join();

        System.out.println(count);

    }
}

运行结果:

可以看到运行的结果与我们预期的结果不一致,并不是10w,这个现象就是“线程不安全”

不过这到底是是为什么呢?为什么和我们预期的结果不一致,是哪里出问题了呢?

其实我们从下面两个角度解析这个问题:

(1)从开发者角度来看

因为多个线程操作了同一个变量(也就是共享了数据)并且存在至少有一个线程修改了这个变量。

假如多个线程操作了不同的数据,那么就不会有线程安全的问题;假如多个线程操作了一个数据,但都是进行了只读操作,也不会有线程安全问题。

(2)从操作系统的角度来看

count ++ 其实就是 count = count + 1

这行代码,其实对应了三个指令:第一个,LOAD指令,从内存里读取count值;

                                                      第二个,ADD指令,对数据进行 + 1 操作;

                                                      第三个,SAVE指令,把修改后的数据写回到主存中。

而线程的调度是随机的,可能会发生在任意时刻(LOAD | ADD | SAVE) 可能会在LOAD指令的前面,也可能会在ADD指令的前面,也可能会在SAVE指令的前面或者其后面。

下面我们来说几种调度情况:

      t2在t1执行完结束后开始执行。这种的一个顺序执行,那是没有问题的,两个线程对count进行了2次的(+1)操作,count=2  注意主内存中的count=0

        t2在t1的LOAD(读数据)操作后,ADD(修改数据)操作前开始执行,由于t1和t2线程都有自己的工作内存,在从主内存读数据到自己的工作内存中进行修改的过程中,两个线程不会影响彼此,所以这种情况就是两个线程都是从count=0开始,分别对count进行了一次(+1)操作,所以最后往主内存中写入count的值是1.  也就是说两个线程加了两次,但只生效了一次,这就是bug产生的根源。

t2在t1的ADD(修改数据)操作之后,SAVE(将数据写入主内存)操作之前开始执行,由于t2读数据是从主内存中读取的,尽管是在t1线程修改了数据后,但未写数据到主内存中时,t2线程读的数据还是主内存中的count=0值,所以结果同上,最终只生效了一次,最终主内存中的count=1。

也就是说,只要两个线程不是串行执行的,就一定会有问题,就会产生“线程不安全”问题。

以上就是产生线程不安全的原因。

总结:产生线程不安全,我们归其原因是因为线程安全的特性

1.线程调度的随机性(这是根本原因,但是咱们解决不了,无可奈何)

2.多个线程对同一个变量进行修改操作(也不一定能解决,看需求)

3.原子性

count++ 就是一个非原子性的操作,针对共享变量的操作,是非原子性的。(如果对于共享变量的操作是原子性的,那么就不会有线程安全问题的产生。)

4.内存可见性

一个线程对于数据的操作,很可能其他线程是无感知的,甚至某些情况下,会被编译器优化成完全看不到(比如:线程更新完数据后,没有立马更新到主内存;线程立马更新到主内存,但是其他的线程没有及时从主内存里读数据)

5.指令重排序

编译器会对代码进行一定的优化。程序员是期望代码从上到下去执行的,但是实际上,编译器会进行一些优化,使得代码的顺序被打乱。当然,编译器也不是随意的调整代码的顺序,要遵循“happen-before”原则。简单来说,在单线程的情况,不改变程序的结果,但是多线程的场景比较复杂,所以,不能保证。

三、线程不安全问题的解决办法

如何解决线程不安全问题呢?我们从以下三个方面入手

1.原子性

什么是原子性? 我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入 房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性 的。那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进 不来了。这样就保证了这段代码的原子性了。 有时也把这个现象叫做同步互斥,表示操作是互相排斥的。一条 java 语句不一定是原子的,也不一定只是一条指令。比如刚才我们看到的 count++,其实是由三步操作组成的: 1. 从内存把数据读到 CPU 2. 进行数据更新 3. 把数据写回到 CPU

synchronized通过加锁的方式,实现了原子性,内存可见性,对于指令重排序,有一定的约束,但不是完全禁止。

       对于上面的t1和t2线程就是将LOAD、ADD、SAVE三个指令操作打包在一起,通过加锁的方式,把两个线程对数据的操作,变成了串行操作,让其非原子性变为原子性,就解决了线程不安全问题。 取数据之前,先加锁,在取数据操作,最后解锁出来。

通过synchronized加锁,代码如何去写呢?

锁的对象可以是java的任何对象,通过synchronized,把for那块代码绑定在一起,变成了原子性。两个线程必须对同一个对象进行加锁,这两个线程才能构成竞争。

synchronized是加锁,放在不同的地方,锁的对象也不一样。放在代码块前,锁对象可以是任意对象;放在普通方法前,加锁对象就是该实例;放在静态方法前,加锁对象就是静态方法。

通过synchronized加锁,解决线程不安全的具体使用方法。

(1)修饰代码块

放在代码块前,锁对象可以是任意对象。通过synchronize,把这块代码,绑定在一起,变成原子性。

public class Demo1 {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker){
                for (int i = 0; i < 5_0000; i++) {
                    count++;
                }
            }

        });
    }
}

(2)修饰普通方法

锁对象是该实例。同一个实例,同时执行fun1,fun2时,只能有一个方法执行。

public class Student {

    static int count = 80;

    public synchronized void fun1(){
        //....具体代码
    }
    public synchronized void fun2(){
        //... 具体代码
    }

    public void fun3(){
        synchronized (this){

        }
    }
}

(3)修饰静态方法

静态方法不属于任意一个实例类方法。修饰静态方法时,锁对象是“类对象”,类对象!=类实例。

public static synchronized void fun4(){

    }
    public void fun5(){
        synchronized (Student.class){

        }
    }

接下来让我们来看一下在不同的情况下,t1线程和t2线程是否会互斥(是否会争夺资源)。

synchronized与join的区别:join更多是控制线程结束的,把两个线程改成了串行执行,而synchronized加锁,线程还是并发执行的

2.内存可见性。

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

volatile不能保证原子性,但是可以保证内存可见性,禁止指令重排序。

3.指令重排序

什么是代码重排序 ?一段代码是这样的:

1. 去前台取下 U 盘

2. 去教室写 10 分钟作业

3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问 题,可以少跑一次前台。这种叫做指令重排序

编译器对于指令重排序的前提是 "保持逻辑不发生变化". 这一点在单线程环境下比较容易判断, 但 是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代 码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值