zoukankan      html  css  js  c++  java
  • 并发编程基础(上)

    从我开始写博客到现在,已经写了不少关于并发编程的了,差不多还有一半内容整个并发编程系列就结束了,而今天这篇博客是比较简单的,只是介绍下并发编程的基础知识( = =!其实,对于大神来说,前面所有博客都是基础)。本来我不太想写这篇博客,因为这篇博客的很多内容都是以记忆为主,而且网上也有大把大把的博客,都写的相当不错,但是我最终决定还是要写一写,因为没有这篇博客,并发编程系列就不能算是一个完整的系列。

    什么是线程

    说到线程,不得不说到进程,因为线程是无法单独存在的,它只是进程中的一部分。
    进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位。线程则是进程的一个执行路径,一个进程中至少有一个线程。操作系统在分配系统资源的时候,会把CPU资源分配给线程,因为真正执行工作,需要占用CPU运行的是线程,所以也可以说线程是CPU分配的基本单位。
    在Java中,我们启动一个main函数,就启动了一个JVM的进程,而main函数所在的线程被称为“主线程”。
    每个线程都有一个叫“程序计数器”的私有的内存区域,用来记录当前线程下一个要执行的指令地址,为什么要把程序计数器设计成私有的呢?因为线程是占用CPU的基本单位,而CPU一般是使用时间片轮转的方式来让线程占有的,所以当某个线程的时间片用完后,要让出CPU,等下一次获得时间片了,再继续执行。那么线程怎么知道之前的程序执行到哪里了呢?就是靠程序计数器。另外需要注意的是,如果执行的是native方法,那么程序计数器记录的是undefined地址。

    线程的三种创建方式与区别

    线程有三种创建方式,分别是

    1. 继承Thread类并重写run方法;
    2. 实现Runnable接口的run方法;
    3. 实现Callable泛型接口,并实现call方法。
    class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("run");
        }
    }
    
    
    public class MyTest {
        public static void main(String[] args) {
            MyThread myThread = new MyThread();
            myThread.start();
        }
    }
    
    class MyRannable implements Runnable {
        @Override
        public void run() {
            System.out.println("run");
        }
    }
    
    public class MyTest {
        public static void main(String[] args) {
            MyRannable myRannable = new MyRannable();
            new Thread(myRannable).start();
        }
    }
    
    class MyCallable implements Callable<String> {
        @Override
        public String call() throws Exception {
            return "MyCallable";
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            FutureTask<String>futureTask=new FutureTask<>(new MyCallable());
            new Thread(futureTask).start();
            System.out.println(futureTask.get());
        }
    }
    

    相信前面两种方式不用多说,大家都懂。我们现在来看看第三种方式,首先定义了MyCallable类,并实现了Callable接口的call方法。在main方法中创建了FutureTask对象,传入了MyCallable对象,然后用Thread包装了FutureTask对象,随后启动,最后用futureTask提供的get方法获取结果,获取结果这一步是阻塞的。

    在面试中,经常会问如下的问题:

    • 调用Thread的start方法会发生什么事情,线程会马上执行吗?
      不会,调用Thread的start方法,线程没有马上执行,它处于“就绪”的状态,需要获得CPU资源后才会执行。
    • 调用Thread的start和run方法,有什么区别?
      调用start方法,才会真正开启新的线程执行run中的方法,而调用run方法,只是和调用普通方法一样,不会开启线程。
    • 上面三种方式的区别是什么,优缺点?
      只有Thread才是真正的线程,其他两种方法都需要被Thread包装才可以成为线程,在run方法中,可以使用this来获得当前线程,不需要使用Thread.currentThread(),缺点在于Java是单继承的,如果继承了Thread类,就没有办法继承其他类了,这是比较致命的。
      Runnable是接口,所以实现了Runnable接口,还可以继承其他的类,但是必须被Thread类包装才可以成为线程,可以被线程池管理。
      以上两种方法都是没有返回值的,所以第三种方式Callable出现了,也可以被线程池管理,同样的,也必须被Thread类包装才可以成为线程。

    线程的状态

    • 新建:当创建Thread的实例后,此线程进行新建状态。如:Thread t1 = new Thread() 。(但是也有一些博客对这个提出了强烈的反对,认为new Thread()只是创建了一个普通的Java对象而已,和线程或者线程的状态八竿子打不着,不过认为创建Thread实例后,线程就处于新建状态的说法确实是主流)
    • 就绪:当调用了start方法后,线程不会马上执行,此时线程的状态是“就绪”,等待分配CPU资源。
    • 运行:线程获得CPU资源后,真正开始执行。
    • 死亡:当线程运行结束后,进入“死亡”状态,处于此状态的线程永远都不会再次进入“就绪”。
    • 阻塞:由于某种原因导致正在运行的线程让出CPU并暂停自己的执行,就进入了“阻塞”的状态,比如调用线程的sleep方法,对象的wait方法等。当满足条件被返回后,线程重新进入“就绪”的状态,再次等待分配CPU资源。

    关于死亡和阻塞状态,其实说的不太完整,因为除了线程运行结束后这种“自然死亡”,还有一个情况,就是被stop了,但是Java已经不推荐使用stop等操作了,所以就忘记吧,阻塞也是同样的道理,也不推荐使用suspend方法了,也忘记它把。

    线程通知与等待

    在Java中,每个对象都继承了Object类,而在Object类中提供了通知和等待的操作,所以每个对象都有这样的操作,既然是线程的通知与等待,为什么要把它定义在Object类中?因为Java提供的锁,锁的是对象,而不是方法或是线程,所以自然要定义在Object类中。

    wait

    当一个线程调用共享变量的wait方法后,该线程会被阻塞挂起,直到发生以下的两个事情才返回:

    1. 其他线程调用了该对象的notify或者notifyAll方法;
    2. 其他线程调用了该线程的interrupt方法,该线程会被返回,并且抛出InterruptedException异常。
    class MyRunnable implements Runnable {
        Object object=new Object();
    
        @Override
        public void run() {
            try {
               synchronized (object){
                   object.wait();
                   System.out.println("run");
               }
            } catch (InterruptedException e) {
                System.out.println("被中断了");
                e.printStackTrace();
            }
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Thread thread = new Thread(new MyRunnable());
            thread.start();
            thread.interrupt();
        }
    }
    

    运行结果:

    被中断了
    java.lang.InterruptedException
    	at java.lang.Object.wait(Native Method)
    	at java.lang.Object.wait(Object.java:502)
    	at com.codebear.MyRunnable.run(MyTest.java:13)
    	at java.lang.Thread.run(Thread.java:748)
    

    首先新建了一个子线程,子线程内部获取了object的监视器锁,随后调用object的wait方法阻塞当前线程,主线程调用interrupt方法中断子线程,子线程被返回,并且产生了异常。

    这也就是为什么我们在调用共享变量的wait方法的时候,Java“死皮赖脸”的要我们对异常进行处理的原因:
    image.png

    调用wait方法后,还会释放对共享变量的监视器锁,让其他线程可以进入临界区:

    class MyRunnable implements Runnable {
        @Override
        public void run() {
            try {
                synchronized (MyRunnable.class) {
                    System.out.println("我是" + Thread.currentThread().getName() + ",我进入了临界区");
                    MyRunnable.class.wait();
                    Thread.sleep(Integer.MAX_VALUE);
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            System.out.println("run");
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            MyRunnable myRunnable = new MyRunnable();
            Thread thread1 = new Thread(myRunnable);
            thread1.start();
            Thread thread2 = new Thread(myRunnable);
            thread2.start();
        }
    }
    

    运行结果:

    我是Thread-1,我进入了临界区
    我是Thread-0,我进入了临界区
    

    可以很清楚的看到,两个线程都进入了临界区。
    线程A获取了共享对象的监视器锁后,进入了临界区,线程B只能等待,线程A调用了共享对象的wait方法后,释放了共享对象的监视器锁,让线程B也可以获得共享变量的监视器锁,并且进入临界区。

    在调用共享变量的wait方法前,必须先对该共享变量进行synchronized操作,否则会抛出IllegalMonitorStateException异常:

    class MyRunnable implements Runnable {
        Object object = new Object();
    
        @Override
        public void run() {
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("run");
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Thread thread = new Thread(new MyRunnable());
            thread.start();
            thread.interrupt();
        }
    }
    

    运行结果:

    Exception in thread "Thread-0" java.lang.IllegalMonitorStateException
    	at java.lang.Object.wait(Native Method)
    	at java.lang.Object.wait(Object.java:502)
    	at com.codebear.MyRunnable.run(MyTest.java:12)
    	at java.lang.Thread.run(Thread.java:748)
    

    另外需要注意的是,一个线程虽然从阻塞挂起的状态到就绪的状态,但是可能其他线程并没有唤醒它,这就是虚假唤醒,虽然虚假唤醒在实践中很少发生,但是防患于未然,比较严谨的做法就是在wait方法外面,包裹一个while循环,while循环的条件就是检测是否满足了被唤醒的条件,这样即使虚假唤醒发生了,该线程被返回了,由于被while包裹了,发现并没有满足被唤醒的条件,又会被再次wait。
    如下所示:

      while(是否满足了被唤醒的条件) {
         object.wait();
      }
    

    notify

    wait方法是将当前线程阻塞挂起,那么必定有一个方法是唤醒此线程的,就像沉睡的白雪公主也在等待王子的到来,将她唤醒一样。
    被唤醒的线程不能马上从wait方法处返回,并且继续执行,因为还需要再次获取共享变量的监视器锁(因为调用wait方法后,已经释放了监视器,所以这里需要再次获取)。
    如果有多个线程都调用了共享变量的wait方法而被阻塞挂起,那么调用notify方法后,只会随机唤醒其中一个线程。
    还有一点尤其需要注意:当调用共享变量的notify方法后,并没有释放共享变量的监视器锁,只有退出临界区或者调用wait方法后,才会释放共享变量的监视器锁,我们可以做一个实验:

    class CodeBearRunnable implements Runnable {
    
        private Object object = new Object();
    
        @Override
        public void run() {
            synchronized (object) {
                object.notify();
                System.out.println("我是" + Thread.currentThread().getName() + LocalDateTime.now());
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    public class NotifyTest {
        public static void main(String[] args) {
            CodeBearRunnable codeBearRunnable = new CodeBearRunnable();
            new Thread(codeBearRunnable).start();
            new Thread(codeBearRunnable).start();
        }
    }
    

    运行结果:

    我是Thread-02019-04-28T18:12:19.195
    我是Thread-12019-04-28T18:12:22.196
    

    我们来分析下代码:当线程A获取了共享变量的监视器锁,进入了临界区,调用共享变量的notify方法,打印出当前的时间,随后sleep当前线程3秒。如果notify方法会释放锁,那么线程B打印出来时间和线程A打印出来的时间应该相差不大,但是可以很清楚的看到,打印出来的时间相差了3秒,说明了线程A调用共享变量的notify方法后,并没有释放共享变量的锁,只有退出了临界区,才释放了共享变量的锁。

    notifyAll

    如果有多个线程都调用了共享变量的wait方法而被阻塞挂起,那么调用notifyAll方法后,所有线程都会被唤醒。

    最后,我们用一个常见的面试题来熟悉下wait/notify的应用:两个线程交替打印奇偶数:

    class MyRunnable implements Runnable {
        static private int i = 0;
    
        @Override
        public void run() {
            try {
                while (i < 100) {
                    synchronized (MyRunnable.class) {
                        MyRunnable.class.notify();
                        MyRunnable.class.wait();
                        System.out.println("我是" + Thread.currentThread() + ":" + i++);
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Thread thread1 = new Thread(new MyRunnable());
            thread1.start();
    
            Thread thread2 = new Thread(new MyRunnable());
            thread2.start();
        }
    }
    

    运行结果:
    image.png

    join

    在开发中,我们经常会遇到这样的需求:等待某些事情都完成后,才可以继续执行。比如旅游网站查询某个产品的航班,航班可以分为去程和返程,我们可以开两个线程同时查询去程和返程的航班,等他们的结果都返回后,再执行其他操作。

    class GoRunnable implements Runnable {
        @Override
        public void run() {
           System.out.println("查询去程航班");
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    class ReturnRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("查询返程航班");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Thread thread1 = new Thread(new GoRunnable());
            thread1.start();
    
            Thread thread2 = new Thread(new ReturnRunnable());
            thread2.start();
            System.out.println("开始查询航班,现在的时间是"+ LocalDateTime.now());
            thread1.join();
            thread2.join();
            System.out.println("航班查询完毕,现在的时间是"+ LocalDateTime.now());
        }
    }
    

    运行结果:

    查询去程航班
    查询返程航班
    开始查询航班,现在的时间是2019-04-28T21:18:05.719
    航班查询完毕,现在的时间是2019-04-28T21:18:10.654
    

    如果是同步查询,那么查询航班的耗时应该在(5+3)秒左右,现在利用线程+join方法,两个线程同时执行,耗时5秒左右(取决于慢的那个),在实际项目中,可以提升用户的体验,大幅提高查询的效率。

    这里仅仅是演示join的功能,如果在实际项目中遇到这样的场景应该不会用join这么“粗糙”的方法。

    让我们再来看看当join遇到interrupt方法会擦出怎样的火花:

    class GoRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("查询去程航班");
            for (; ; ) {
            }
        }
    }
    
    public class MyTest {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Thread thread1 = new Thread(new GoRunnable());
            thread1.start();
            Thread.currentThread().interrupt();
            try {
                thread1.join();
            } catch (InterruptedException e) {
                System.out.println("主线程" + e.toString());
            }
        }
    }
    

    运行结果:

    主线程java.lang.InterruptedException
    查询去程航班
    

    子线程内部是一个死循环,执行子线程后,中断主线程,在主线程中的thread1.join处抛出了异常。但是需要注意的是,因为中断的是主线程,所以是在主线程中抛出异常,这里我用try包住thread1.join()只是为了更好的展现错误,其实这里并不强制要求对异常进行捕获。

    本来想用一篇博客就结束并发编程基础的,但是写起来才发现想多了,一是想把每个知识点都说的清楚一点,并给出各种例子来帮助大家更好的理解,二是并发编程基础的知识点确实挺多的,所以还是分两篇博客来吧。

  • 相关阅读:
    树莓派使用记录 修改国内软件源《二》
    树莓派使用记录 安装系统《一》
    C# 委托 Action 实现代码执行时间日志记录
    微软 Visual Studio 离线下载
    项目框架搭建工具
    WebApi 重写 DefaultHttpControllerSelector 实现路由重定向
    开发相关网页收藏
    造SQL语句
    报错:Every derived table must have its own alias
    html
  • 原文地址:https://www.cnblogs.com/CodeBear/p/10811124.html
Copyright © 2011-2022 走看看