zoukankan      html  css  js  c++  java
  • Java多线程0:核心理论

      并发编程是Java程序员最重要的技能之一,也是最难掌握的一种技能。它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰、思维缜密,这样才能写出高效、安全、可靠的多线程并发程序。本系列会从线程间协调的方式(wait、notify、notifyAll)、Synchronized及Volatile的本质入手,详细解释JDK为我们提供的每种并发工具和底层实现机制。在此基础上,我们会进一步分析java.util.concurrent包的工具类,包括其使用方式、实现源码及其背后的原理。本文是该系列的第一篇文章,是这系列中最核心的理论部分,之后的文章都会以此为基础来分析和解释。

    一、共享性

      数据共享是为什么要考虑线程安全的主要原因之一。如果所有的数据只是在当前线程内有效,那就不需要考虑线程安全问题。但是,在多线程编程中,数据共享是不可避免的。比如夫妻双方一人在柜台取钱,一人在ATM上取钱,两个取钱线程共享账户中的余额这一变量,这时候就要考虑线程安全问题了。

      举例1:以银行取钱为例说明多线程之间的数据共享

      定义一个账户Account,成员变量为账户编号和余额,以及一个取钱的方法,方法中对取钱金额做了判断,只有取的钱<=账户余额的时候,才能取钱成功。

    public class Account {
    
        //账户编号
        private int accountNo;
        //账户余额
        private int balance;
    
        public Account(int accountNo, int balance) {
            this.accountNo = accountNo;
            this.balance = balance;
        }
    
        //取钱方法
        public void drawMoney(int drawMoneyCount){
            if(balance >= drawMoneyCount){
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功");
                    this.balance = balance - drawMoneyCount;
                    System.out.println("账户余额为===" + (this.balance));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
    
            }else{
                System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足");
            }
        }
    }

      定义一个取钱线程类:

    public class Thread03 extends Thread{
    
        private Account account;
    
        private int drawMoneyCount;
        
        //初始化账户余额和取钱金额
        public Thread03(int drawMoneyCount,Account account){
            this.drawMoneyCount = drawMoneyCount;
            this.account = account;
        }
    
        @Override
        public void run() {
            account.drawMoney(drawMoneyCount);
        }
    }

      测试,定义两个取钱线程

    public class Test {
        public static void main(String[] args) {
            Account account = new Account(123456789,800);
            //取钱线程1
            Thread thread1 = new Thread03(500,account);
            thread1.setName("张三");
            //取钱线程2
            Thread thread2 = new Thread03(500,account);
            thread2.setName("李四");
            thread1.start();
            thread2.start();
        }
    }

      结果:

    张三取款金额为 = 500,取款成功
    账户余额为===300
    李四取款金额为 = 500,取款成功
    账户余额为===-200

      说明:可以看到,初始账户余额为800,张三取了500,账户余额还剩300,李四取500的时候,按说已经对取钱金额进行校验,不应该取钱成功,但李四还是取出了500。账户余额还剩-200。这是因为两个取钱的线程同时进入到Account的drawMoney方法内部,校验金额的时候都是800>=500,所以都能取钱成功。

      解决方法就是对取钱方法进行同步,用synchronized修饰,确保一个取钱线程对共享变量账户余额操作时,另一个取钱线程处于阻塞状态。此处先看一下结果,后续会详细解释synchronized锁机制。

    public class Account {
    
        //账户编号
        private int accountNo;
        //账户余额
        private int balance;
    
        public Account(int accountNo, int balance) {
            this.accountNo = accountNo;
            this.balance = balance;
        }
    
        //取钱方法
        public synchronized void drawMoney(int drawMoneyCount){
            if(balance >= drawMoneyCount){
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功");
                    this.balance = balance - drawMoneyCount;
                    System.out.println("账户余额为===" + (this.balance));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
    
            }else{
                System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足");
            }
        }
    }

      其余都不变,看一下结果:

    张三取款金额为 = 500,取款成功
    账户余额为===300
    李四取款金额为 = 500,取款失败,余额不足

      所以,多线程情况下对共享变量的操作,要考虑线程安全问题。

      举例2:以银行存钱、取钱为例说明多线程之间的数据共享

    public class Account {
    
        //账户编号
        private int accountNo;
        //账户余额
        private int balance;
    
        public Account(int accountNo, int balance) {
            this.accountNo = accountNo;
            this.balance = balance;
        }
    
        //取钱方法
        public void drawMoney(int drawMoneyCount){
            if(balance >= drawMoneyCount){
                System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功");
                this.balance = balance - drawMoneyCount;
                System.out.println("账户余额为===" + (this.balance));
    
            }else{
                System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足");
            }
        }
    
        //存钱方法
        public void depositMoney(int depositMoneyCount){
            System.out.println(Thread.currentThread().getName() + "存钱成功,余额为 = " + (this.balance + depositMoneyCount));
            this.balance = balance + depositMoneyCount;
        }
    }

       定义一个取钱线程类

    public class Thread03 extends Thread{
    
        private Account account;
    
        private int drawMoneyCount;
    
        //初始化账户余额和取钱金额
        public Thread03(int drawMoneyCount,Account account){
            this.drawMoneyCount = drawMoneyCount;
            this.account = account;
        }
    
        @Override
        public void run() {
            account.drawMoney(drawMoneyCount);
        }
    }

       定义一个存钱线程类

    public class Thread04 extends Thread{
    
        private Account account;
    
        private int depositMoneyCount;
    
        //初始化账户余额和存钱金额
        public Thread04(int depositMoneyCount, Account account){
            this.depositMoneyCount = depositMoneyCount;
            this.account = account;
        }
    
        @Override
        public void run() {
            account.depositMoney(depositMoneyCount);
        }
    }

       测试,初始账户余额为0,进行存钱取钱。

    public class Test {
        public static void main(String[] args) {
            Account account = new Account(123456789,0);
            //存钱线程
            Thread thread1 = new Thread04(200,account);
            thread1.setName("张三");
            //取钱线程
            Thread thread2 = new Thread03(100,account);
            thread2.setName("李四");
            thread1.start();
            thread2.start();
        }
    }

      结果:(其中一种) 

    张三存钱成功,余额为 = 200
    李四取款金额为 = 100,取款失败,余额不足

       说明:可以看到,张三已经存了200块钱,余额已经是200,但是李四取100没有成功。说明李四存钱的时候,张三同时进行了取钱操作,因为两个线程操作的是同一个对象的变量(account对象的balance变量),所以,张三存钱还没结束的时候,李四取钱的时候余额还是0,所以会失败。

      解决方法就是对存钱方法和取钱方法进行同步,用synchronized修饰,确保取钱时,存钱线程处于阻塞状态,存钱时,取钱线程处于阻塞状态,这样就不会发生两个线程同一时间对同一对象的变量进行操作。此处先看一下结果,后续会详细解释synchronized锁机制。

    public class Account {
    
        //账户编号
        private int accountNo;
        //账户余额
        private int balance;
    
        public Account(int accountNo, int balance) {
            this.accountNo = accountNo;
            this.balance = balance;
        }
    
        //取钱方法
        public synchronized void drawMoney(int drawMoneyCount){
            if(balance >= drawMoneyCount){
                System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款成功");
                this.balance = balance - drawMoneyCount;
                System.out.println("账户余额为===" + (this.balance));
    
            }else{
                System.out.println(Thread.currentThread().getName() + "取款金额为 = " + drawMoneyCount + ",取款失败,余额不足");
            }
        }
    
        //存钱方法
        public synchronized void depositMoney(int depositMoneyCount){
            System.out.println(Thread.currentThread().getName() + "存钱成功,余额为 = " + (this.balance + depositMoneyCount));
            this.balance = balance + depositMoneyCount;
        }
    }

       其余不变,看一下结果:

    张三存钱成功,余额为 = 200
    李四取款金额为 = 100,取款成功
    账户余额为===100

       所以,多线程情况下对共享变量的操作,要考虑线程安全问题。

     二、互斥性

      资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。

      如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。但是对共享数据的写操作,一般就需要保证互斥性,上述例子中就是因为没有保证互斥性才导致存钱取钱出现问题。Java 中提供多种机制来保证互斥性,最简单的方式是使用Synchronized,参见上述加了synchronized的结果。

    三、原子性

      原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。但是很多操作不能通过一条指令就完成。例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,我们经常使用的整数 i++ 的操作,其实需要分成三个步骤:(1)读取整数 i 的值;(2)对 i 进行加一操作;(3)将结果写回内存。这个过程在多线程下就可能出现如下现象:

    这也是代码段一执行的结果为什么不正确的原因。对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的Synchronized或Lock都可以实现,代码段二就是通过Synchronized实现的。除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。

    四、可见性

      要理解可见性,需要先对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似,如图所示:

      

      从这个图中我们可以看出,每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,这么做的目的还是在于进一步缩小存储系统与CPU之间速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。

      举例:

    public class Thread01 extends Thread{
        private boolean runningFlag = true;
    
        public void setRunningFlag(boolean runningFlag) {
    
            this.runningFlag = runningFlag;
            System.out.println(Thread.currentThread().getName() + "线程set runningFlag = " + runningFlag);
        }
    
        public boolean isRunningFlag() {
            return runningFlag;
        }
    
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "线程进入run方法,runningFlag = " + runningFlag);
            while(runningFlag){
    
            }
            System.out.println("run方法执行完成,线程停止");
        }
    }

      测试

    public class Test {
        public static void main(String[] args) {
            Thread01 thread = new Thread01();
            thread.start();
            try {
                Thread.sleep(1000);
                thread.setRunningFlag(false);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
        }
    }

      结果:

      说明:可以看到,runningFlag已经被设置为false,但是Thread-0线程还是没有停止。出现打印结果现象的原因就是主内存和工作内存中数据的不同步造成的。因为Thread-0执行run()方法的时候拿到一个主内存runningFlag的拷贝,而设置runningFlag是在main线程中做的,换句话说 ,设置的runningFlag设置的是主内存中的runningFlag,更新了主内存的runningFlag,线程Thread-0工作内存中的runningFlag没有更新,还是true,当然一直死循环了。

      volatile的作用就是这样,被volatile修饰的变量,保证了每次读取到的都是最新的那个值。线程安全围绕的是可见性原子性这两个特性展开的,volatile解决的是变量在多个线程之间的可见性,但是无法保证原子性

      多提一句,synchronized除了保障了原子性外,其实也保障了可见性。因为synchronized无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。

      解决这个问题很简单,用volatile修饰runningFlag即可,加上了volatile的意思是,线程Thread-0每次读取runningFlag的值的时候,都先从主内存中把runningFlag同步到线程的工作内存中,再获取当前时刻最新的runningFlag。看一下给runningFlag加了volatile关键字的运行效果:

    public class Thread01 extends Thread{
        private volatile boolean runningFlag = true;
    
        public void setRunningFlag(boolean runningFlag) {
    
            this.runningFlag = runningFlag;
            System.out.println(Thread.currentThread().getName() + "线程set runningFlag = " + runningFlag);
        }
    
        public boolean isRunningFlag() {
            return runningFlag;
        }
    
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + "线程进入run方法,runningFlag = " + runningFlag);
            while(runningFlag){
    
            }
            System.out.println("run方法执行完成,线程停止");
        }
    }

      结果:

    五、有序性

      为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:

      (1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

      (2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
      (3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

      我们可以直接参考一下JSR 133 中对重排序问题的描述:

      

            (1)                    (2)

      先看上图中的(1)源码部分,从源码来看,要么指令 1 先执行要么指令 3先执行。如果指令 1 先执行,r2不应该能看到指令 4 中写入的值。如果指令 3 先执行,r1不应该能看到指令 2 写的值。但是运行结果却可能出现r2==2,r1==1的情况,这就是“重排序”导致的结果。上图(2)即是一种可能出现的合法的编译结果,编译后,指令1和指令2的顺序可能就互换了。因此,才会出现r2==2,r1==1的结果。Java 中也可通过Synchronized或Volatile来保证顺序性。

    六 总结

      本文对Java 并发编程中的理论基础进行了讲解,有些东西在后续的分析中还会做更详细的讨论,如可见性、顺序性等。后续的文章都会以本章内容作为理论基础来讨论。如果大家能够很好的理解上述内容,相信无论是去理解其他并发编程的文章还是在平时的并发编程的工作中,都能够对大家有很好的帮助。

    参考资料:

    Java 并发编程:核心理论

    Java多线程6:synchronized锁定类方法、volatile关键字及其他

    Java多线程之原子性 volatile、atomicInteger测试

  • 相关阅读:
    将CMD内的显示内容输出到txt文件
    上传项目到Github
    利用百度地图API,在浏览器中找到自己的位置
    操作系统相关知识
    字符串的全排列
    二维数组做函数参数、指向指针的指针做函数参数
    strstr函数与strcmp函数
    华为机试题——数组排序,且奇数存在奇数位置,偶数存在偶数位置
    单链表的建立,插入,显示,查找删除以及反转
    《深入了解 Linq to SQL》之对象的标识 —— 麦叔叔呕心呖血之作
  • 原文地址:https://www.cnblogs.com/zfyang2429/p/10579940.html
Copyright © 2011-2022 走看看