JAVA多线程-线程安全问题

一、CPU多核缓存架构

CPU分为三级缓存: 每个CPU都有L1,L2缓存,但是L3缓存是多核公用的。

CPU查找数据的顺序为: CPU -> L1 -> L2 -> L3 -> 内存 -> 硬盘

进一步优化,CPU每次读取一个数据,并不是仅仅读取这个数据本身,而是会读取与它相邻的64个字节的数据,称之为缓存行,因为CPU认为,我使用了这个变量,很快就会使用与它相邻的数据,这是计算机的局部性原理。这样,就不需要每次都从主存中读取数据了。一个缓存行现在是64个字节,这是很多科学家调优的结果,如果设计的太小则难以命中,如果设计的大了则读取比较慢,这是目前的最优解。

缓存行 (Cache Line) 便是 CPU Cache 中的最小单位,CPU Cache 由若干缓存行组成,一个缓存行的大小通常是 64 字节(这取决于 CPU),并且它有效地引用主内存中的一块地址。

多级缓存架构下最典型的问题就是可见性问题,可以简单的理解为,一个线程修改的值对其他线程可能不可见。

比如两个CPU读取了一个缓存行,缓存行里有两个变量,一个x一个y。第一颗CPU修改了x的数据,还没有刷回主存,此时第二颗CPU,从主存中读取了未修改的缓存行,而此时第一颗CPU修改的数据刷回主存,这时就出现,第二颗CPU读取的数据和主存不一致的情况。

除了存在可见性的问题,当多个线程同时修改相同资源的时候,还会存在资源争夺问题。

除了增加高速缓存之外,为了使处理器内部的运算单元尽量被充分利用。处理器可能会对输入的代码进行乱序执行,优化处理器会在计算之后将乱序执行的结果进行重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句的先后执行顺序与输入代码中的顺序一致。因此如果存在一个计算任务,依赖于另外一个依赖任务的中间,结果那么顺序性不能靠代码的先后顺序来保证。 Java虚拟机的即时编译器中也有指令重排的优化。

二、JMM模型中存在的问题

2.1、指令重排

我们写一个例子来证明指令重排的存在

public class OutOfOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    private static int count = 0;

    private static volatile int NUM = 0;

    public static void main(String[] args)
            throws InterruptedException {
        long start = System.currentTimeMillis();
        for (;;) {
            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("一共执行了:" + (count++) + "次");
            if(x==0 && y==0){
                long end = System.currentTimeMillis();
                System.out.println("耗时:+"+ (end-start) +"毫秒,(" + x + "," + y + ")");
                break;
            }
            a=0;b=0;x=0;y=0;
        }
    }
}

我们的印象中,不论怎么执行,这个程序有可能是(x=0,y=1)即t1先与t2执行,也有可能是(x=1,y=0)即t1后与t2执行,但是现实是可能会出现(1,1)和(0,0),但是按道理绝对不会出现(0,0),因为出现零的情况一定是x = b; y = a; a = 1; b = 1;,如果出现了也就证明了我们的执行在执行的时候确实存在乱序。

怎么避免乱序执行呢,我们可以使用volatile关键字来保证一个变量在一次读写操作时的避免指令重排

2.2、可见性

我们来证明一下一个线程对数据的修改对于另一个线程不可见

public class Test {
    private static boolean isOver = false;

    private static int number = 0;

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) {}
                System.out.println(number);
            }
        });
        thread.start();
        ThreadUtils.sleep(1000);
        number = 50;
        // 已经改了啊,应该可以退出上边循环的值了啊!
        isOver = true;
    }
}

我们的主线程已经修改了isOver,按道理新创建的线程要停下来来输出number,实际上永元也不会输出,isOver因为新的线程的频繁使用,已经被加载到一级缓存,CPU再次读取isOver是直接从一级缓存读取的,并没有从内存读取,所以主线程修改了内存中的值并没有效果。

怎样避免出现可见性问题呢?volatile能强制对改变量的读写直接在主存中操作,从而解决了不可见的问题。

JMM用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。

2.3、资源争夺

我们来证明一下一个线程之间存在资源争抢的问题

public class Test {
    private static int COUNT = 0;

    public static void adder(){
         COUNT++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                adder();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                adder();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最后的结果是:"+COUNT);
    }
}

最后我们发现每次的结果都不一样,都是10000以上的数字,这足以说明问题了,一个线程的结果对另一个线程不可见。

解决线程争抢问题的最好的方案就是加锁,这里我们解释另外一个问题,就是为什么volatile它不行,非得用synchronized,我们解释一个volatile做了什么,我们的CPU运行速度很快很快,主存是不够快的,所以我们会有cachecache就存在这样一个问题,cache中的数据命中了,CPU对数据进行了修改,cache中数据被修改了,要同步给主存,别的线程是看不到主存的改变的,JAVA内存模型中,每个线程都有自己的缓存,这完蛋了,内存不可见,所以volatile登场,它强制性的将我们修改的数据刷入每一个使用到了我们修改数据的线程的缓存中,可是这样就数据安全了嘛,其实并不安全,为什么?我们刚刚提到了CPU很快,内存很慢的,比如说我们的a++这个指令在线程1中执行的其实是 a=a+1,我们线程1将a=1刷回主存之后,会给线程2强制刷新,线程2的a都自加到10了,其实必然发生错误,也就是说volatile只保证了可见性,但是并没有保证操作的原子性,跟新不及时。volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存,这样任何时刻,不同的线程总能看到该变量的最新值。

synchronized就不一样了,它保证的是原子性,也就是说被synchronized加锁的操作,不论多少个线程只能有一个线程去操作它,它是线程安全的,volatile是线程不安全的。但是 synchronized是一个很重的操作

三、线程安全的实现方法

3.1、数据不可变

在Java当中,一切不可变的对象(immutable)一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障的措施,比如final关键字修饰的基础数据类型,再比如说咱们的Java字符串儿。只要一个不可变的对象被正确的构建出来,那外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态,带来的安全性是最直接最纯粹的。

3.2、互斥同步

互斥同步是常见的一种并发正确性的保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用,互斥是实现同步的一种手段,互斥是因、同步是果,互斥是方法,同步是目的。

互斥同步面临的主要问题是,进行线程阻塞和唤醒带来的性能开销,因此这种同步也被称为阻塞同步,从解决问题的方式上来看互斥同步是一种悲观的并发策略,其总是认为,只要不去做正确的同步措施,那就肯定会出现问题,无论共享的数据是否真的出现,都会进行加锁。这将会导致用户态到内核态的转化、维护锁计数器和检查是否被阻塞的线程需要被唤醒等等开销。

3.3、非阻塞同步

随着硬件指令级的发展,我们已经有了另外的选择,基于冲突检测的乐观并发策略。通俗的说,就是不管有没有风险,先进行操作,如果没有其他线程征用共享数据,那就直接成功,如果共享数据确实被征用产生了冲突,那就再进行补偿策略,常见的补偿策略就是不断的重试,直到出现没有竞争的共享数据为止,这种乐观并发策略的实现,不再需要把线程阻塞挂起,因此同步操作也被称为非阻塞同步,这种措施的代码也常常被称之为无锁编程,也就是咱们说的自旋。我们用cas来实现这种非阻塞同步。

3.4、无同步方案

在我们这个工作当中,还经常遇到这样一种情况,多个线程需要共享数据,但是这些数据又可以在单独的线程当中计算,得出结果,而不被其他的线程所影响,如果能保证这一点,我们就可以把共享数据的可见范围限制在一个线程之内,这样就无需同步,也能够保证个个线程之间不出现数据征用的问题,说人话就是数据拿过来,我用我的,你用你的,从而保证线程安全,比如说ThreadLocal

四、并发编程三要素

4.1、原子性

原子性指的是一个或者多个操作,要么全部执行并且在执行的过程中不被其他操作打断,要么就全部都不执行。

4.2、可见性

可见性指多个线程操作一个共享变量时,其中一个线程对变量进行修改后,其他线程可以立即看到修改的结果。

4.3、有序性

有序性,即程序的执行顺序按照代码的先后顺序来执行。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

LyaJpunov

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

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

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

打赏作者

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

抵扣说明:

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

余额充值