多线程并发的秘密

为什么需要多线程?

我们大家都知道,cpu,内存,磁盘三者在处理速度上是有着较大的差异,所以为了合理的利用cpu的资源,平衡三者的速度差异。所以计算机操作系统做了以下三件事

  • CPU增加了缓存-->均衡了cup与内存的速度差异-->导致可见性问题
  • 操作系统增加了进程,线程,以分时复用CPU-->均衡了cpu和磁盘速度差异-->导致原子性问题
  • 编译程序优化指令执行次序-->使得缓存得以充分利用-->导致有序性问题

可见性

可见性:一个线程对共享变量的修改,能够被其它线程立即看到

代码:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

假设执行线程1的代码是cpu1,线程2执行的代码是cpu2,由上面的分析可知,由于cpu增加了缓存,cpu1执行完i=10这条语句后,会将i的初始值加载到cpu的高速缓存中,然后赋值为10,即在cpu1中的高速缓存中的值变为10,但是并没有立即写入主存

此时cpu2会执行j=i的命令,cpu2会去主存中读取i的值,但是此时主存当中的i的值为0,那么就会导致cpu2所拿到的值为0而非10

分析:线程1对变量i修改后,线程2并没有立即看到线程1修改的值,没有保证可见性,导致可见性问题

影响:看如下实例代码

public class ThreadUnsafeExample {

    private int i = 0;

    public void add() {
        i++;
    }

    public int get() {
        return i;
    }

    /**
     * 使用1000个线程对变量i进行++操作
     *
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {

        // 线程数量
        final int threadSize = 1000;

        // 新建操作i的对象
        ThreadUnsafeExample example = new ThreadUnsafeExample();

        // 用线程池技术创建线程
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executorService = Executors.newCachedThreadPool();

        // 循环1000次,创建1000个线程
        for (int i = 0; i < threadSize; i++) {
            // 每个线程触发对i++操作
            executorService.execute(() -> {
                example.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();

        // 输出i的值
        System.out.println(example.get());
    }
}

执行结果:

i的值为 = 979

由结果看出,每次执行程序的结果都会小于1000,且每次都会有不同的值。这就是线程与线程之间的可见性问题,导致有的线程在操作i的值时还为将其写入内存就被其它线程所读取。导致小于1000

原子性

原子性:即一个操作或者多个操作,要么全部执行,要么全部不执行

经典的转账问题:

比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题

有序性

有序性:即程序执行过程中按照代码的先后顺序执行


int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

从代码顺序上来看,语句1是在语句2执行之前执行的,那么jvm在执行程序时语句1一定在语句2之前执行吗,答案是否定的,因为可能会发生指令重排序

总结

若不遵循以上3个性质,就会导致并发性问题

java怎么解决这并发性问题呢?

分析了可能引起并发的三个原因,所以java需要保持以上三大特性即可解决并发问题

保证可见性

Java提供了volatile关键字来保证可见性

当一个共享变量被voatile所修饰时,它会保证修改的值会立即更新到内存中去,当有其它线程读取时,获取的是最新的值,即保证了可见性

而普通的共享变量不能保证可见性,因为普通的共享变量在被修改之后,何时被加载到内存当中是不确定的,因此无法保证可见性

另外,synchronized和lock也能够保证可见性,两者可通过保证同一时刻只有一个线程获取锁然后执行同步代码,并且在锁释放之前会将变量的修改刷新到主存当中,因为保证了可见性

保证原子性

在java中,对基本数据变量的读取和赋值是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

分析以下以下那条命令是原子性

请分析以下哪些操作是原子性操作:

x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x;         //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1;     //语句4: 同语句3

答案:上面四条语句只有第一条语句才是原子性操作

也就是说简单的读取,赋值才是原子性操作

如果想实现更大的原子性操作,可以通过synchronized和lock来实现,两者都保证了任意时刻只有一个线程执行该同步代码块,也即不存在原子性问题,从而保证了原子性

保证有序性

在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的。

总结:分析出了影响并发性问题的因素,即保证这三大特性即可避免并发性问题

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值