美文网首页
并发编程(一)-多线程&并发设计原理

并发编程(一)-多线程&并发设计原理

作者: Alan1914 | 来源:发表于2021-07-28 16:18 被阅读0次

    [toc]

    简介

    java是一个支持多线程的开发语言。多线程可以在包含多个CPU核心的机器上同时处理多个不同的任务,优化资源的使用率,提升程序的效率。在一些对性能要求比较高场合,多线程是java程序调优的重要方面。

    Java并发编程主要涉及以下几个部分:

    1. 并发编程三要素
    • 原子性:即一个不可再被分割的颗粒。在Java中原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
    • 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
    • 可见性:当多个线程访问同一个变量时,如果其中一个线程对其作了修改,其他线程能立即获取到最新的值。
    1. 线程的五大状态
    • 创建状态:当用 new 操作符创建一个线程的时候
    • 就绪状态:调用 start 方法,处于就绪状态的线程并不一定马上就会执行 run 方法,还需要等待CPU的调度
    • 运行状态:CPU 开始调度线程,并开始执行 run 方法
    • 阻塞状态:线程的执行过程中由于一些原因进入阻塞状态比如:调用 sleep 方法、尝试去得到一个锁等等
    • 死亡状态:run 方法执行完 或者 执行过程中遇到了一个异常
    image.png
    1. 悲观锁与乐观锁
    • 悲观锁:每次操作都会加锁,会造成线程阻塞。
    • 乐观锁:每次操作不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止,不会造成线程阻塞。
    1. 线程之间的协作
    • 线程间的协作有:wait/notify/notifyAll等
    1. synchronized 关键字
      synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
    • 修饰一个代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来
      的代码,作用的对象是调用这个代码块的对象
    • 修饰一个方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象
      是调用这个方法的对象
    • 修饰一个静态的方法:其作用的范围是整个静态方法,作用的对象是这个类的所有对
    • 修饰一个类:其作用的范围是synchronized后面括号括起来的部分,作用主的对象
      是这个类的所有对象。
    1. CAS
      CAS全称是Compare And Swap,即比较替换,是实现并发应用到的一种技术。操作包含三个操作数—内存位置(V)预期原值(A)新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
      CAS存在三大问题:ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。

    2. 线程池
      如果我们使用线程的时候就去创建一个线程,虽然简单,但是存在很大的问题。如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。线程池通过复用可以大大减少线程频繁创建与销毁带来的性能上的损耗。

    常见的多线程面试题有:

    1. 重排序有哪些分类?如何避免?
    2. Java 中新的Lock接口相对于同步代码块(synchronized block)有什么优势?如果让你实现一个高性能缓存,支持并发读取和单一写入,你如何保证数据完整性。
    3. 如何在Java中实现一个阻塞队列。
    4. 写一段死锁代码。说说你在Java中如何解决死锁。
    5. volatile变量和atomic变量有什么不同?
    6. 为什么要用线程池?
    7. 实现Runnable接口和Callable接口的区别
    8. 执行execute()方法和submit()方法的区别是什么呢?
    9. AQS的实现原理是什么?
    10. java API中哪些类中使用了AQS?

    多线程和并发设计原理

    1. 多线程概念

    1.1. Thread和Runnable

    java中的线程

    创建执行线程有两种方法:
    扩展Thread 类。

    public class MyThread extends Thread {
    
        private String name;
    
        public MyThread(String name) {
            this.name = name;
        }
    
        @Override
        public void run() {
            for(int i = 0 ; i < 10 ; i++){
                System.out.println("Thread :" + this.name + ",i= " + i );
            }
        }
    }
    
    public class ThreadDemo {
    
        public static void main(String[] args) {
            MyThread myThread1 = new MyThread("thread 1");
            MyThread myThread2 = new MyThread("thread 2");
            MyThread myThread3 = new MyThread("thread 3");
            myThread1.start();
            myThread2.start();
            myThread3.start();
        }
    }
    

    实现Runnable 接口。

    public class MyRunnable implements Runnable{
    
        private String name;
    
        public MyRunnable(String name) {
            this.name = name;
        }
    
        @Override
        public void run() {
            for(int i = 0 ; i < 10 ; i++){
                System.out.println("Thread :" + this.name + ",i= " + i );
            }
        }
    }
    
    public class RunnableDemo {
        public static void main(String[] args) {
            MyRunnable myRunnable1 = new MyRunnable("Runnable 1");
            MyRunnable myRunnable2 = new MyRunnable("Runnable 2");
            MyRunnable myRunnable3 = new MyRunnable("Runnable 3");
            Thread thread1 = new Thread(myRunnable1);
            Thread thread2 = new Thread(myRunnable2);
            Thread thread3 = new Thread(myRunnable3);
            thread1.start();
            thread2.start();
            thread3.start();
        }
    }
    

    实现Callable 接口。

    public class FutureExample {
    
    
        static class MyCallable implements Callable<String> {
            @Override
            public String call() throws Exception {
                log.info("do something in callable ");
                Thread.sleep(5000);
                return "Done";
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
    
            ExecutorService exec = Executors.newCachedThreadPool();
            Future<String> future = exec.submit(new MyCallable());
            log.info("do something in main");
            Thread.sleep(1000);
            try {
                String result = future.get();
                log.info("result: {}", result);
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
    
    
        }
        
    }
    

    Java中的线程:特征和状态

    1. 所有的Java 程序,不论并发与否,都有一个名为主线程的Thread 对象。执行该程序时, Java虚拟机( JVM )将创建一个新Thread 并在该线程中执行main()方法。这是非并发应用程序中唯一的线程,也是并发应用程序中的第一个线程。
    2. Java中的线程共享应用程序中的所有资源,包括内存和打开的文件,快速而简单地共享信息。但是必须使用同步避免数据竞争。
    3. Java中的所有线程都有一个优先级,这个整数值介于Thread.MIN_PRIORITY(1)和
      Thread.MAX_PRIORITY(10)之间,默认优先级是Thread.NORM_PRIORITY(5)。线程的执行顺序并没有保证,通常,较高优先级的线程将在较低优先级的钱程之前执行。
    4. 在Java 中,可以创建两种线程:
    • 守护线程。
    • 非守护线程。
      区别在于它们如何影响程序的结束。
      Java程序结束执行过程的情形:
      程序执行Runtime类的exit()方法, 而且用户有权执行该方法。
      应用程序的所有非守护线程均已结束执行,无论是否有正在运行的守护线程。
      守护线程通常用在作为垃圾收集器或缓存管理器的应用程序中,执行辅助任务。在线程start之前调用isDaemon()方法检查线程是否为守护线程,也可以使用setDaemon()方法将某个线程确立为守护线程。
    1. Thread.States类中定义线程的状态如下:
    • NEW:Thread对象已经创建,但是还没有开始执行。
    • RUNNABLE:Thread对象正在Java虚拟机中运行。
    • BLOCKED : Thread对象正在等待锁定。
    • WAITING:Thread 对象正在等待另一个线程的动作。
    • TIME_WAITING:Thread对象正在等待另一个线程的操作,但是有时间限制。
    • TERMINATED:Thread对象已经完成了执行。
      getState()方法获取Thread对象的状态,可以直接更改线程的状态。
      在给定时间内, 线程只能处于一个状态。这些状态是JVM使用的状态,不能映射到操作系统的线程状态。


      image.png

    1.2. synchronized关键字

    锁的对象

        // 修饰一个代码块
        public void test1(int j) {
            synchronized (this) {
                for (int i = 0; i < 10; i++) {
                    log.info("test1 {} - {}", j, i);
                }
            }
        }
    
        // 修饰一个方法
        public synchronized void test2(int j) {
            for (int i = 0; i < 10; i++) {
                log.info("test2 {} - {}", j, i);
            }
        }
    
       // 修饰一个静态类
        public static void test1(int j) {
            synchronized (SynchronizedExample2.class) {
                for (int i = 0; i < 10; i++) {
                    log.info("test1 {} - {}", j, i);
                }
            }
        }
    
        // 修饰一个静态方法
        public static synchronized void test2(int j) {
            for (int i = 0; i < 10; i++) {
                log.info("test2 {} - {}", j, i);
            }
        }
    

    注:代码详见 https://github.com/db0n15/alancode/tree/master/concurrency

    锁的本质

    如果一份资源需要多个线程同时访问,需要给该资源加锁。加锁之后,可以保证同一时间只能有一个线程访问该资源。资源可以是一个变量、一个对象或一个文件等。

    image.png

    锁是一个“对象”,作用如下:

      1. 这个对象内部得有一个标志位(state变量),记录自己有没有被某个线程占用。最简单的情况是这个state有0、1两个取值,0表示没有线程占用这个锁,1表示有某个线程占用了这个锁。
      1. 如果这个对象被某个线程占用,记录这个线程的thread ID。 3. 这个对象维护一个thread id list,记录其他所有阻塞的、等待获取拿这个锁的线程。在当前线程释放锁之后从这个thread id list里面取一个线程唤醒。

    要访问的共享资源本身也是一个对象,例如前面的对象myClass,这两个对象可以合成一个对象。代码就变成synchronized(this) {…},要访问的共享资源是对象a,锁加在对象a上。当然,也可以另外新建一个对象,代码变成synchronized(obj1) {…}。这个时候,访问的共享资源是对象a,而锁加在新建的对象obj1上。
    资源和锁合二为一,使得在Java里面,synchronized关键字可以加在任何对象的成员上面。这意味着,这个对象既是共享资源,同时也具备“锁”的功能!

    锁的实现原理

    锁如何实现?
    在对象头里,有一块数据叫Mark Word。在64位机器上,Mark Word是8字节(64位)的,这64位中有2个重要字段:锁标志位和占用该锁的thread ID。因为不同版本的JVM实现,对象头的数据结构会有各种差异。

    wait & notify

    轻量级阻塞与重量级阻塞

    能够被中断的阻塞称为轻量级阻塞,对应的线程状态是WAITING或者TIMED_WAITING;而像synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED。如图所示:调用不同的方法后,一个线程的状态迁移过程。

    image.png

    初始线程处于NEW状态,调用start()开始执行后,进入RUNNING或者READY状态。如果没有调用任何的阻塞函数,线程只会在RUNNING和READY之间切换,也就是系统的时间片调度。这两种状态的切换是操作系统完成的,除非手动调用yield()函数,放弃对CPU的占用。
    一旦调用了图中的任何阻塞函数,线程就会进入WAITING或者TIMED_WAITING状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了synchronized关键字或者synchronized块,则会进入BLOCKED状态。
    不太常见的阻塞/唤醒函数,LockSupport.park()/unpark()。这对函数非常关键,Concurrent包中Lock的实现即依赖这一对操作原语。
    因此thread.interrupted()的精确含义是“唤醒轻量级阻塞”,而不是字面意思“中断一个线程”。

    thread.isInterrupted()与Thread.interrupted()的区别
    因为 thread.interrupted() 相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于WAITING或者TIMED_WAITING状态,就会抛出一个InterruptedException,并且线程被唤醒。而如果线程此时并没有被阻塞,则线程什么都不会做。但在后续,线程可以判断自己是否收到过其他线程发来的中断信号,然后做一些对应的处理。
    这两个方法都是线程用来判断自己是否收到过中断信号的,前者是实例方法,后者是静态方法。二者的区别在于,前者只是读取中断状态,不修改状态;后者不仅读取中断状态,还会重置中断标志位。

    线程的优雅关闭

    stop与destory函数

    线程是“一段运行中的代码”,一个运行中的方法。运行到一半的线程能否强制杀死?
    不能。在Java中,有stop()、destory()等方法,但这些方法官方明确不建议使用。原因很简单,如果强制杀死线程,则线程中所使用的资源,例如文件描述符、网络连接等无法正常关闭。
    因此,一个线程一旦运行起来,不要强行关闭,合理的做法是让其运行完(也就是方法执行完毕),干净地释放掉所有资源,然后退出。如果是一个不断循环运行的线程,就需要用到线程间的通信机制,让主线程通知其退出。

    守护线程

    当在一个JVM进程里面开多个线程时,这些线程被分成两类:守护线程和非守护线程。默认都是非守护线程
    在Java中有一个规定:当所有的非守护线程退出后,整个JVM进程就会退出。意思就是守护线程“不算作数”,守护线程不影响整个 JVM 进程的退出。
    例如,垃圾回收线程就是守护线程,它们在后台默默工作,当开发者的所有前台线程(非守护线程)都退出之后,整个JVM进程就退出了。

    2. 并发核心概念

    2.1. 并发和并行

    在单个处理器上采用单核执行多个任务即为并发。在这种情况下,操作系统的任务调度程序会很快从一个任务切换到另一个任务,因此看起来所有的任务都是同时运行的。
    同一时间内在不同计算机、处理器或处理器核心上同时运行多个任务,就是所谓的“并行”。
    另一个关于并发的定义是,在系统上同时运行多个任务(不同的任务)就是并发。而另一个关于并行的定义是:同时在某个数据集的不同部分上运行同一任务的不同实例就是并行。
    关于并行的最后一个定义是,系统中同时运行了多个任务。关于并发的最后一个定义是,一种解释程序员将任务和它们对共享资源的访问同步的不同技术和机制的方法。
    这两个概念非常相似,而且这种相似性随着多核处理器的发展也在不断增强。

    2.2. 同步

    在并发中,我们可以将同步定义为一种协调两个或更多任务以获得预期结果的机制。同步的方式有两种:

    • 控制同步:例如,当一个任务的开始依赖于另一个任务的结束时,第二个任务不能再第一个任务完成之前开始。
    • 数据访问同步:当两个或更多任务访问共享变量时,再任意时间里,只有一个任务可以访问该变量。

    与同步密切相关的一个概念时临界段。临界段是一段代码,由于它可以访问共享资源,因此再任何给定时间内,只能被一个任务执行。互斥是用来保证这一要求的机制,而且可以采用不同的方式来实现。

    同步可以帮助你在完成并发任务的同时避免一些错误,但是它也为你的算法引入了一些开销。你必须非常仔细地计算任务的数量,这些任务可以独立执行,而无需并行算法中的互通信。这就涉及并发算法的粒度。如果算法有着粗粒度(低互通信的大型任务),同步方面的开销就会较低。然而,也许你不会用到系统所有的核心。如果算法有者细粒度(高互通信的小型任务),同步方面的开销就会很高,而且该算法的吞吐量可能不会很好。

    并发系统中有不同的同步机制。从理论角度看,最流行的机制如下:

    • 信号量(semaphore):一种用于控制对一个或多个单位资源进行访问的机制。它有一个用
      于存放可用资源数量的变量,而且可以采用两种原子操作来管理该变量。互斥(mutex,
      mutual exclusion的简写形式)是一种特殊类型的信号量,它只能取两个值(即资源空闲和资
      源忙),而且只有将互斥设置为忙的那个进程才可以释放它。互斥可以通过保护临界段来帮助你避免出现竞争条件。
    • 监视器:一种在共享资源上实现互斥的机制。它有一个互斥、一个条件变量、两种操作(等待条件和通报条件)。一旦你通报了该条件,在等待它的任务中只有一个会继续执行。
      如果共享数据的所有用户都受到同步机制的保护,那么代码(或方法、对象)就是线程安全的。数据的非阻塞的CAS(compare-and-swap,比较和交换)原语是不可变的,这样就可以在并发应用程序中使用该代码而不会出任何问题。

    2.3. 不可变对象

    不可变对象是一种非常特殊的对象。在其初始化后,不能修改其可视状态(其属性值)。如果想修改一个不可变对象,那么你就必须创建一个新的对象。
    不可变对象的主要优点在于它是线程安全的。你可以在并发应用程序中使用它而不会出现任何问题。
    不可变对象的一个例子就是java中的String类。当你给一个String对象赋新值时,会创建一个新的String对象。

    2.4. 原子操作和原子变量

    与应用程序的其他任务相比,原子操作是一种发生在瞬间的操作。在并发应用程序中,可以通过一个临界段来实现原子操作,以便对整个操作采用同步机制。
    原子变量是一种通过原子操作来设置和获取其值的变量。可以使用某种同步机制来实现一个原子变量,或者也可以使用CAS以无锁方式来实现一个原子变量,而这种方式并不需要任何同步机制。

    2.5. 共享内存与消息传递

    任务可以通过两种不同的方式来相互通信。第一种方法是共享内存,通常用于在同一台计算机上运行多任务的情况。任务在读取和写入值的时候使用相同的内存区域。为了避免出现问题,对该共享内存的访问必须在一个由同步机制保护的临界段内完成。
    另一种同步机制是消息传递,通常用于在不同计算机上运行多任务的情形。当一个任务需要与另一个任务通信时,它会发送一个遵循预定义协议的消息。如果发送方保持阻塞并等待响应,那么该通信就是同步的;如果发送方在发送消息后继续执行自己的流程,那么该通信就是异步的。

    3. 并发的问题

    3.1. 数据竞争

    如果有两个或者多个任务在临界段之外对一个共享变量进行写入操作,也就是说没有使用任何同步机制,那么应用程序可能存在数据竞争(也叫做竞争条件)。

    3.2. 死锁

    当两个(或多个)任务正在等待必须由另一线程释放的某个共享资源,而该线程又正在等待必须由前述任务之一释放的另一共享资惊时,并发应用程序就出现了死锁。当系统中同时出现如下四种条件时,就会导致这种情形。我们将其称为Coffman 条件。

    • 互斥: 死锁中涉及的资师、必须是不可共享的。一次只有一个任务可以使用该资源。
      占有并等待条件: 一个任务在占有某一互斥的资源时又请求另一互斥的资源。当它在等待时,不会释放任何资源。
    • 不可剥夺:资源只能被那些持有它们的任务释放。
    • 循环等待:任务1正等待任务2 所占有的资源, 而任务2 又正在等待任务3 所占有的资源,以
      此类推,最终任务n又在等待由任务1所占有的资源,这样就出现了循环等待。

    有一些机制可以用来避免死锁。

    • 忽略它们:这是最常用的机制。你可以假设自己的系统绝不会出现死锁,而如果发生死锁,结果就是你可以停止应用程序并且重新执行它。
    • 检测:系统中有一项专门分析系统状态的任务,可以检测是否发生了死锁。如果它检测到了死锁,可以采取一些措施来修复该问题,例如,结束某个任务或者强制释放某一资源。
    • 预防:如果你想防止系统出现死锁,就必须预防Coffman 条件中的一条或多条出现。
    • 规避:如果你可以在某一任务执行之前得到该任务所使用资源的相关信息,那么死锁是可以规避的。当一个任务要开始执行时,你可以对系统中空闲的资源和任务所需的资源进行分析,这样就可以判断任务是否能够开始执行。

    3.3. 活锁

    如果系统中有两个任务,它们总是因对方的行为而改变自己的状态, 那么就出现了活锁。最终结果是它们陷入了状态变更的循环而无法继续向下执行。
    例如,有两个任务:任务1和任务2 ,它们都需要用到两个资源:资源1和资源2 。假设任务1对资源1加了一个锁,而任务2 对资源2 加了一个锁。当它们无法访问所需的资源时,就会释放自己的资源并且重新开始循环。这种情况可以无限地持续下去,所以这两个任务都不会结束自己的执行过程。

    3.4. 资源不足

    当某个任务在系统中无法获取维持其继续执行所需的资源时,就会出现资源不足。当有多个任务在等待某一资源且该资源被释放时,系统需要选择下一个可以使用该资源的任务。如果你的系统中没有设计良好的算法,那么系统中有些线程很可能要为获取该资源而等待很长时间。
    要解决这一问题就要确保公平原则。所有等待某一资源的任务必须在某一给定时间之内占有该资源。可选方案之一就是实现一个算法,在选择下一个将占有某一资源的任务时,对任务已等待该资源的时间因素加以考虑。然而,实现锁的公平需要增加额外的开销,这可能会降低程序的吞吐量。

    3.5. 优先权反转

    当一个低优先权的任务持有了一个高优先级任务所需的资源时,就会发生优先权反转。这样的话,低优先权的任务就会在高优先权的任务之前执行。

    4. JMM内存模型

    4.1. JMM与happen-before

    为什么会存在“内存可见性”问题

    重排序与内存可见性的关系

    相关文章

      网友评论

          本文标题:并发编程(一)-多线程&并发设计原理

          本文链接:https://www.haomeiwen.com/subject/psiwmltx.html