Java 线程和多线程执行过程分析

栏目: Java · 发布时间: 6年前

内容简介: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线程先执行,也可能更先消亡。

Java 线程和多线程执行过程分析

创建线程方式二:

  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();
    }
}

执行结果大致如下:

Java 线程和多线程执行过程分析

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

Java 线程和多线程执行过程分析

共开启了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();
    }
}

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

本文永久更新链接地址 http://www.linuxidc.com/Linux/2018-01/150101.htm


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

京东技术解密

京东技术解密

京东研发体系 / 电子工业出版社 / 2014-11-18 / 65

京东高速的增长、闪电响应的供应链、庞大的团队规模等背后内幕,对于业界一直像谜一样神秘。随着成为中国B2C领导厂商以及在纳斯达克上市,京东越来越需要开放自己,与业界形成更好的交流与融合。《京东技术解密》的面世,就是京东技术团队首次向业界集体亮相。本书用翔实的内容为读者逐一解答——如何用技术支撑网站的综合竞争实力,如何把握技术革新的时间点,如何应对各种棘手问题及压力,如何在网站高速运转的情况下进行系统......一起来看看 《京东技术解密》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试