zoukankan      html  css  js  c++  java
  • Java 线程和多线程执行过程分析

    本文目录:
    1.几个基本的概念
    2.创建线程的两种方法
    3.线程相关的常用方法
    4.多线程安全问题和线程同步
     4.1 多线程安全问题
     4.2 线程同步
     4.3 同步代码块和同步函数的区别以及锁是什么
     4.4 单例懒汉模式的多线程安全问题
    5.死锁(DeadLock)

    1.几个基本的概念

    本文涉及到的一些概念,有些是基础知识,有些在后文会展开详细的说明。

    1. 进程(Process):一个程序运行起来时在内存中开辟一段空间用来运行程序,这段空间包括heap、stack、data segment和code segment。例如,开一个QQ就表明开了一个QQ进程。
    2. 线程(Thread):每一个进程中都至少有一个线程。线程是指程序中代码运行时的运行路径,一个线程表示一条路径。例如QQ进程中,发送消息、接收消息、接收文件、发送文件等各种独立的功能都需要一个线程来执行。
    3. 进程和线程的区别:从资源的角度来考虑,进程主要考虑的是CPU和内存,而线程主要考虑的是CPU的调度,某进程中的各线程之间可以共享这个进程的很多资源。
      从粒度粗细来考虑,进程的粒度较粗,进程上下文切换时消耗的CPU资源较多。线程的粒度要小的多,虽然线程也会切换,但因为共享进程的上下文,相比进程上下文切换而言,同进程内的线程切换时消耗的资源要小的多的多。在JAVA中,除了java运行时启动的JVM是一个进程,其他所有任务都以线程的方式执行,也就是说java应用程序是单进程的,甚至可以说没有进程的概念。
    4. 线程组(ThreadGroup):线程组提供了一些批量管理线程的方法,因此通过将线程加入到线程组中,可以更方便地管理这些线程。
    5. 线程的状态:就绪态、运行态、睡眠态。还可以分为存活和死亡,死亡表示线程结束,非死亡则存活,因此存活包含就绪、运行、睡眠。
    6. 中断睡眠(interrupt):将线程从睡眠态强制唤醒,唤醒后线程将进入就绪队列等待cpu调度。
    7. 并发操作:多个线程同时操作一个资源。这会带来多线程安全问题,解决方法是使用线程同步。
    8. 线程同步:让线程中的某些任务原子化,即要么全部执行完毕,要么不开始执行。通过互斥锁来实现同步,通过监视这个互斥锁是否被谁持有来决定是否从睡眠态转为就绪态(即从线程池中出去),也就是是否有资格去获取cpu的执行权。线程同步解决了线程安全的问题,但降低了程序的效率。
    9. 死锁:线程全睡眠了无法被唤醒,导致程序卡死在某一处无法再执行下去。典型的是两个同步线程,线程1持有A锁,且等待B锁,但线程2持有B锁且等待A锁,这样的僵局会造成死锁。但需要注意的是,死锁并非都是因为僵局,只要两边的线程都无法继续向下执行代码(或者两边的线程池都无法被唤醒,这是等价的概念,因为锁等待也会让进程进入睡眠态),则都是死锁

    还需需要明确的一个关键点是:CPU对就绪队列中每个线程的调度是随机的(对我们人类来说),且分配的时间片也是随机的(对人类来说)。

    2.创建线程的两种方法

    Java中有两种创建线程的方式。

    创建线程方式一:

    1. 继承Thread类(在java.lang包中),并重写该类的run()方法,其中run()方法即线程需要执行的任务代码。
    2. 然后new出这个类对象。这表示创建线程对象。
    3. 调用start()方法开启线程来执行任务(start()方法会调用run()以便执行任务)。

    例如下面的代码中,在主线程main中创建了两个线程对象,先后并先后调用start()开启这两个线程,这两个线程会各自执行MyThread中的run()方法。

    class MyThread extends Thread {
        String name;
        String gender;
    
        MyThread(String name,String gender){
            this.name = name;
            this.gender = gender;
        }
    
        public void run(){
            int i = 0;
            while(i<=20) {
                //除了主线程main,其余线程从0开始编号,currentThread()获取的是当前线程对象
                System.out.println(Thread.currentThread().getName()+"-----"+i+"------"+name+"------"+gender);
                i++;
            }
        }
    }
    
    public class CreateThread {
        public static void main(String[] args) {
            MyThread mt1 = new MyThread("malong","Male");
            MyThread mt2 = new MyThread("Gaoxiao","Female");
    
            mt1.start();
            mt2.start();
            System.out.println("main thread over");
        }
    }
    

    上面的代码执行时,有三个线程,首先是主线程main创建2个线程对象,并开启这两个线程任务,开启两个线程后主线程输出"main thread over",然后main线程结束。在开启两个线程任务后,这两个线程加入到了就绪队列等待CPU的调度执行。如下图。因为每个线程被cpu调度是随机的,执行时间也是随机的,所以即使mt1先开启任务,但mt2可能会比mt1线程先执行,也可能更先消亡。

    创建线程方式二:

    1. 实现Runnable接口,并重写run()方法。
    2. 创建子类对象。
    3. 创建Thread对象来创建线程对象,并将实现了Runnable接口的对象作为参数传递给Thread()构造方法。
    4. 调用start()方法开启线程来执行run()中的任务。
    class MyThread implements Runnable {
        String name;
        String gender;
    
        MyThread(String name,String gender){
            this.name = name;
            this.gender = gender;
        }
    
        public void run(){
            int i = 0;
            while(i<=200) {
                System.out.println(Thread.currentThread().getName()+"-----"+i);
                i++;
            }
        }
    }
    
    public class CreateThread2 {
        public static void main(String[] args) {
            //创建子类对象
            MyThread mt = new MyThread("malong","Male");
            //创建线程对象
            Thread th1 = new Thread(mt);
            Thread th2 = new Thread(mt);
    
            th1.start();
            th2.start();
            System.out.println("main thread over");
        }
    }
    

    这两种创建线程的方法,无疑第二种(实现Runnable接口)要好一些,因为第一种创建方法继承了Thread后就无法继承其他父类。

    3.线程相关的常用方法

    Thread类中的方法:

    • isAlive():判断线程是否还活着。活着的概念是指是否消亡了,对于运行态、就绪态、睡眠态的线程都是活着的状态。
    • currentThread():返回值为Thread,返回当前线程对象。
    • getName():获取当前线程的线程名称。
    • setName():设置线程名称。给线程命名还可以使用构造方法Thread(String thread_name)Thread(Runnable r,String thread_name)
    • getPriority():获取线程优先级。优先级范围值为1-10(默认值为5),相邻值之间的差距对cpu调度的影响很小。一般使用3个字段MIN_PRIORITY、NORM_PRIORITY、MAX_PRIORITY分别表示1、5、10三个优先级,这三个优先级可较大地区分cpu的调度。
    • setPriority():设置线程优先级。
    • run():封装的是线程开启后要执行的任务代码。如果run()中没有任何代码,则线程不做任何事情。
    • start():开启线程并让线程开始执行run()中的任务。
    • toString():返回线程的名称、优先级和线程组。
    • sleep(long millis):让线程睡眠多少毫秒。
    • join(t1):将线程t1合并到当前线程,并等待线程t1执行完毕后才继续执行当前线程。即让t1线程强制插队到当前线程的前面并等待t1完成。
    • yield():将当前正在执行的线程退让出去,以让就绪队列中的其他线程有更大的几率被cpu调度。即强制自己放弃cpu,并将自己放入就绪队列。由于自己也在就绪队列中,所以即使此刻自己放弃了cpu,下一次还是可能会立即被cpu选中调度。但毕竟给了机会给其它就绪态线程,所以其他就绪态线程被选中的几率要更大一些。

    Object类中的方法:

    • wait():线程进入某个线程池中并进入睡眠态。等待notify()或notifyAll()的唤醒。
    • notify():从某个线程池中随机唤醒一个睡眠态的线程。
    • notifyAll():唤醒某个线程池中所有的睡眠态线程。

    这里的某个线程池是由锁对象决定的。持有相同锁对象的线程属于同一个线程池。见后文。

    一般来说,wait()和唤醒的notify()或notifyAll()是成对出现的,否则很容易出现死锁。

    sleep()和wait()的区别:(1)所属类不同:sleep()在Thread类中,wait()则是在Object中;(2)sleep()可以指定睡眠时间,wait()虽然也可以指定睡眠时间,但大多数时候都不会去指定;(3)sleep()不会抛异常,而wait()会抛异常;(4)sleep()可以在任何地方使用,而wait()必须在同步代码块或同步函数中使用;(5)最大的区别是sleep()睡眠时不会释放锁,不会进入特定的线程池,在睡眠时间结束后自动苏醒并继续往下执行任务,而wait()睡眠时会释放锁,进入线程池,等待notify()或notifyAll()的唤醒。

    java.util.concurrent.locks包中的类和它们的方法:

    • Lock类中:

      • lock():获取锁(互斥锁)。
      • unlock():释放锁。
      • newCondition():创建关联此lock对象的Condition对象。
    • Condition类中:

      • await():和wait()一样。
      • signal():和notify()一样。
      • signalAll():和notifyAll()一样。

    4.多线程安全问题和线程同步

    4.1 多线程安全问题

    线程安全问题是指多线程同时执行时,对同一资源的并发操作会导致资源数据的混乱。

    例如下面是用多个线程(窗口)售票的代码。

    class Ticket implements Runnable {
        private int num;    //票的数量
    
        Ticket(int num){
            this.num = num;
        }
    
        //售票
        public void sale() {
            if(num>0) {
                num--;
                System.out.println(Thread.currentThread().getName()+"-------"+remain());
            }
        }
    
        //获取剩余票数
        public int remain() {
            return num;
        }
    
        public void run(){
            while(true) {
                sale();
            }
        }
    }
    
    public class ConcurrentDemo {
        public static void main(String[] args) {
            Ticket t = new Ticket(100);
            //创建多个线程对象
            Thread t1 = new Thread(t);
            Thread t2 = new Thread(t);
            Thread t3 = new Thread(t);
            Thread t4 = new Thread(t);
    
            //开启多个线程使其执行任务
            t1.start();
            t2.start();
            t3.start();
            t4.start();
        }
    }
    

    执行结果大致如下:

    以上代码的执行过程大致如下图:

    共开启了4个线程执行任务(不考虑main主线程),每一个线程都有4个任务:

    • ①判断if条件if(num>0);
    • ②票数自减num--;
    • ③获取剩余票数return num;
    • ④打印返回的num数量System.out.println(Thread.currentThread().getName()+"-------"+remain())

    这四个任务的共同点也是关键点在于它们都操作同一个资源Ticket对象中的num,这是多线程出现安全问题的本质,也是分析多线程执行过程的切入点

    当main线程开启t1-t4这4个线程时,它们首先进入就绪队列等待被CPU随机选中。(1).假如t1被先选中,分配的时间片执行到任务②就结束了,于是t1进入就绪队列等待被CPU随机选中,此时票数num自减后为99;(2).当t3被CPU选中时,t3所读取到的num也为99,假如t3分配到的时间片在执行到任务②也结束了,此时票数num自减后为98;(3).同理t2被选中执行到任务②结束后,num为97;(4).此时t3又被选中了,于是可以执行任务③,甚至是任务④,假设执行完任务④时间片才结束,于是t3的打印语句打印出来的num结果为97;(5).t1又被选中了,于是任务④打印出来的num也为97。

    显然,上面的代码有几个问题:(1)有些票没有卖出去了但是没有记录;(2)有的票重复卖了。这就是线程安全问题。

    4.2 线程同步

    java中解决线程安全问题的方法是使用互斥锁,也可称之为"同步"。解决思路如下:

    (1).为待执行的任务设定给定一把锁,拥有相同锁对象的线程在wait()时会进入同一个线程池睡眠。
    (2).线程在执行这个设了锁的任务时,首先判断锁是否空闲(即锁处于释放状态),如果空闲则去持有这把锁,只有持有这把锁的线程才能执行这个任务。即使时间片到了,它也不是释放锁,只有wait()或线程结束时才会安全地释放锁。
    (3).这样一来,锁被某个线程持有时,其他线程在锁判断后就继续会线程池睡眠去了(或就绪队列)。最终导致的结果是,(设计合理的情况下)某个线程一定完整地执行完一个任务,其他线程才有机会去持有锁并执行任务。

    换句话说,使用同步线程,可以保证线程执行的任务具有原子性,只要某个同步任务开始执行了就一定执行结束,且不允许其他线程参与。

    让线程同步的方式有两种,一种是使用synchronized(){}代码块,一种是使用synchronized关键字修饰待保证同步的方法。

    class Ticket implements Runnable {
        private int num;    //初始化票的数量
        private Object obj = new Object();
    
        Ticket(int num){
            this.num = num;
        }
    
        //售票
        public void sale() {
            synchronized(obj) {   //使用同步代码块封装需要保证原子性的代码
                if(num>0) {
                    num--;
                    System.out.println(Thread.currentThread().getName()+"-------"+remain());
                }
            }
        }
    
        //获取剩余票数
        public int remain() {
            return num;
        }
    
        public void run(){
            while(true) {
                sale();
            }
        }
    }
    
    class Ticket implements Runnable {
        private int num;    //初始化票的数量
    
        Ticket(int num){
            this.num = num;
        }
    
        public synchronized void sale() {  //使用synchronized关键字,方法变为同步方法
            if(num>0) {
                num--;
                System.out.println(Thread.currentThread().getName()+"-------"+remain());
            }
        }
    
        //获取剩余票数
        public int remain() {
            return num;
        }
    
        public void run(){
            while(true) {
                sale();
            }
        }
    }
    

    使用同步之后,if(num>0)num--return numprint(num)这4个任务就强制具有原子性。某个线程只要开始执行了if语句,它就一定会继续执行直到执行完print(num),才算完成了一整个任务。只有完成了一整个任务,线程才会释放锁(当然,也可能继续判断while(true)并进入下一个循环)。

    4.3 同步代码块和同步函数的区别以及锁是什么

    前面的示例中,同步代码块synchronized(obj){}中传递了一个obj的Object对象,这个obj可以是任意一个对象的引用,这些引用传递给代码块的作用是为了标识这个同步任务所属的锁。

    synchronized函数的本质其实是使用了this作为这个同步函数的锁标识,this代表的是当前对象的引用。但如果同步函数是静态的,即使用了static修饰,则此时this还没出现,它使用的锁是"类名.class"这个字节码文件对象,对于java来说,这也是一个对象,而且一个类中一定有这个对象。

    使用相同的锁之间会互斥,但不同锁之间则没有任何影响。因此,要保证任务同步(原子性),这些任务所关联的锁必须相同。也因此,如果有多个同步任务(各自保证自己的同步性),就一定不能都使用同步函数。

    例如下面的例子中,写了两个相同的sale()方法,并且使用了flag标记让不同线程能执行这两个同步任务。如果出现了多线程安全问题,则表明synchronized函数和同步代码块使用的是不同对象锁。如果将同步代码块中的对象改为this后不出现多线程安全问题,则表明同步函数使用的是this对象。如果为sale2()加上静态修饰static,则将obj替换为"Ticket.class"来测试。

    class Ticket implements Runnable {
        private int num;    //初始化票的数量
        boolean flag = true;
        private Object obj = new Object();
    
        Ticket(int num){
            this.num = num;
        }
    
        //售票
        public void sale1() {
            synchronized(obj) {  //使用的是obj标识锁
                if(num>0) {
                    num--;
                    try{Thread.sleep(1);} catch (InterruptedException i){}  //为了确保num--和println()分开,加上sleep
                    System.out.println(Thread.currentThread().getName()+"===sale1==="+remain());
                }
            }
        }
    
        public synchronized void sale2() {   //使用this标识锁
            if(num>0) {
                num--;
                try{Thread.sleep(1);} catch (InterruptedException i){}
                System.out.println(Thread.currentThread().getName()+"===sale2==========="+remain());
            }
        }
    
        //获取剩余票数
        public int remain() {
            return num;
        }
    
        public void run(){
            if(flag){
                while(true) {
                    sale1();
                }
            } else {
                while(true) {
                    sale2();
                }
            }
        }
    }
    
    public class Mytest {
        public static void main(String[] args) {
            Ticket t = new Ticket(200);
            //创建多个线程对象
            Thread t1 = new Thread(t);
            Thread t2 = new Thread(t);
    
            //开启多个线程使其执行任务
            t1.start();
            try{Thread.sleep(1);} catch (InterruptedException i){}
            t.flag = false;
            t2.start();
        }
    }
    

    以下是执行结果中的一小片段,出现了多线程安全问题。而如果将同步代码块中的obj改为this,则不会出现多线程安全问题。

    Thread-0===sale1===197
    Thread-1===sale2===========197
    Thread-0===sale1===195
    Thread-1===sale2===========195
    Thread-1===sale2===========193
    Thread-0===sale1===193
    Thread-0===sale1===191
    Thread-1===sale2===========191
    

    4.4 单例懒汉模式的多线程安全问题

    单例饿汉式:

    class Single {
        private static final Single s = new Single();
        private Single(){};
        public static Single getInstance() {
            return s;
        }
    }
    

    单例懒汉式:

    class Single {
        private static Single s = null;
        private Single(){};
        public static getInstance(){
            if(s==null) {
                s = new Single();
            }
            return s;
        }
    }
    

    当多线程操作单例饿汉式和懒汉式对象的资源时,是否有多线程安全问题?

    class Demo implements Runnable {
        public void run(){
            Single.getInstance();
        }
    }
    

    以上面的代码为例。当多线程分别被CPU调度时,饿汉式中的getInstance()返回的s,s是final属性修饰的,因此随便哪个线程访问都是固定不变的。而懒汉式则随着不同线程的来临,不断new Single(),也就是说各个线程获取到的对象s是不同的,存在多线程安全问题。

    只需使用同步就可以解决懒汉式的多线程安全问题。例如使用同步方法。

    class Single {
        private static Single s = null;
        private Single(){};
        public static synchronized getInstance(){
            if (s == null){
                s = new Single();
            }
            return s;
        }
    }
    

    这样一来,每个线程来执行这个任务时,都将先判断Single.class这个对象标识的锁是否已经被其他线程持有。虽然解决了问题,但因为每个线程都额外地判断一次锁,导致效率有所下降。可以采用下面的双重判断来解决这个效率降低问题。

    class Single {
        private static Single s = null;
        private Single(){};
        public static getInstance(){
            if (s == null) {
                synchronized(Single.class){
                    if (s == null){
                        s = new Single();
                    }
                    return s;
                }
            }
        }
    }
    

    这样一来,当第一个线程执行这个任务时,将判断s==null为true,于是执行同步代码块并持有锁,保证任务的原子性。而且,即使在最初判断s==null后切换到其他线程了,也没有关系,因为总有一个线程会执行到同步代码块并持有锁,只要持有锁了就一定执行s= new Single(),在这之后,所有的线程在第一阶段的"s==null"判断都为false,从而提高效率。其实,双重判断的同步懒汉式的判断次数和饿汉式的判断次数几乎相等。

    5.死锁(DeadLock)

    最典型的死锁是僵局问题,A等B,B等A,谁都不释放,造成僵局,最后两个线程都无法执行下去。

    例如下面的代码示例,sale1()中,obj锁需要持有this锁才能完成任务整体,而sale2()中,this锁需要持有obj锁才能完成任务整体。当两个线程都开始执行任务后,就开始产生死锁问题。

    class Ticket implements Runnable {
        private int num;    
        boolean flag = true;
        private Object obj = new Object();
    
        Ticket(int num){
            this.num = num;
        }
    
    
        public void sale1() {
            synchronized(obj) {   //obj锁
                sale2();          //this锁
            }
        }
    
        public synchronized void sale2() {   //this锁
            synchronized(obj){               //obj锁
                if(num>0) {
                    num--;
                    try{Thread.sleep(1);} catch (InterruptedException i){}
                    System.out.println(Thread.currentThread().getName()+"========="+remain());
                }
            }
        }
    
        //获取剩余票数
        public int remain() {
            return num;
        }
    
        public void run(){
            if(flag){
                while(true) {
                    sale1();
                }
            } else {
                while(true) {
                    sale2();
                }
            }
        }
    }
    
    public class DeadLockDemo {
        public static void main(String[] args) {
            Ticket t = new Ticket(200);
            //创建多个线程对象
            Thread t1 = new Thread(t);
            Thread t2 = new Thread(t);
    
            //开启多个线程使其执行任务
            t1.start();
            try{Thread.sleep(1);} catch (InterruptedException i){}
            t.flag = false;
            t2.start();
        }
    }
    

    为了避免死锁,尽量不要在同步中嵌套同步,因为这样很容易造成死锁。

    注:若您觉得这篇文章还不错请点击右下角推荐,您的支持能激发作者更大的写作热情,非常感谢!

  • 相关阅读:
    CODING x 百果园 _ 水果零售龙头迈出 DevOps 体系建设第一步
    Nocalhost 亮相 CD Foundation 国内首届 Meetup,Keith Chan 将出席致辞
    做云原生时代标准化工具,实现高效云上研发工作流
    打造数字化软件工厂 —— 一站式 DevOps 平台全景解读
    WePack —— 助力企业渐进式 DevOps 转型
    CODING Compass —— 打造行云流水般的软件工厂
    Nocalhost —— 让云原生开发回归原始而又简单
    CODING 代码资产安全系列之 —— 构建全链路安全能力,守护代码资产安全
    Nocalhost:云原生开发新体验
    使用 Nocalhost 开发 Kubernetes 中的 APISIX Ingress Controller
  • 原文地址:https://www.cnblogs.com/f-ck-need-u/p/8197547.html
Copyright © 2011-2022 走看看