通俗易懂的java多线程(又干货又可爱哦)

    关于多线程的文章我已经写过一篇了,为什么还要再写一篇呢,因为上一篇《讲给女朋友听的java多线程》写的太生涩,也有一定小错误,女朋友并没有看懂。所以,博主又肝了一天,写了这篇通俗易懂,有可爱配图的多线程文章。
    各位大佬,拿去给女朋友看吧。什么?没有女朋友?那就赶紧学java吧,new一个对象出来。

一、前言

    初入江湖的小李,在见识了“南慕容,北乔峰”的飒爽英姿之后,就励志成为一位武林高手。
    小李开始了日复一日的修炼,然而在修炼了一年之后,小李的进境缺很慢,小李百思不得其解,正在恼怒之际,一位仙风道骨的老人传授他一门心法,名叫“多线程”,这门功法的强大之处就在于可以分心多用,同时修炼多种功法,这样一来,小李的进境就很快。
    然而,这门心法缺有一个很大的缺陷,“线程同步问题”,就是在运行心法的时,如果多门功法需要同时使用丹田,这时,如果不小心就会走火入魔。

通过阅读本文,您好将掌握如下知识点:

  • 掌握创建线程的四种方式
  • 掌握线程的调度问题
  • 学会线程同步的三种方式
  • 理解什么是线程死锁
  • 理解线程通信是如何实现的
  • 通过一个典型例题,串起本章知识点

二、线程概述

    本小节介绍什么是程序,什么是进程,什么又是线程,这些概念是本章的基础,刚开始接触,可能会觉得这些概念晦涩难懂,没关系,可以结合后面的实例来理解这些概念。
    注意:线程的概念不必死记硬背,可以在完成本章的学习之后,再来回顾这些概念,你就回你有豁然开朗的感觉。

2.1 概述

  1. 程序(Program):程序可以看做是一辆功能完备的小汽车。
  1. 进程(Process):可以看做是小汽车内部正在运行的发动机。

注意:
    我们可以看到,小汽车是是静态的,即:程序时静态的。而运行中的发动机是动态的,即:进程是动态的。

  1. 线程(thread):可以形象的看做是发动机内部的一个小齿轮。

由上面的三个图片,我们可以抽象出程序,进程,线程的概念:

  1. 程序:计算机程序(Computer Program),也称为软件,简称:程序。是为完成特定任务、用某种语言编写的一组指令的集合。即指一段静态的代码,静态对象。
  2. 进程:是正在运行的一个程序。是一个动态的过程:有它自身的产生、存在和消亡的过程。——生命周期
  3. 进程可进一步细化为线程,是一个程序内部的一条执行路径。

注意:

  • 我们的发动机可以同时运行多个齿轮,它就是多线程的。即:若一个进程同一时间并行执行多个线程,就是支持多线程的。
  • 线程作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器(pc),线程切换的开销小。
  • 类似发动机内部所有齿轮都共享发动机的内部空间,既然都共享了,那总有磕磕碰碰吧,这就引发了线程安全问题。

2.2 并行与并发

  • 并发:同一时刻只能有一条指令执行,但多个进程指令被快速轮换,使得在整体上看见是多个进程同时执行的结果。就好似有两个人共用同一把铁锹,过来一个小时,每人挖了一个小坑。
  • 并行:并行指在同一时刻,有多条指令在多个处理器上执行。就好像两个人一人一把铁锹,一个小时后,每人挖了一个大坑。

    现在,我们的电脑、手机都支持并发的执行程序,比如,我们在逛淘宝的时候,手机还放着音乐,我们在敲代码时,后台可能还运行着API文档,以备我们查看。
    这些程序好像是在同时运行,然而并不是,对一个CPU而言,每个时刻,只能有一个程序在运行,但是CPU会在多个程序之间进行切换,而CPU的切换速度又很快,我们就感觉好像是这些程序在同时执行。

2.3 多线程优点

    单线程的程序往往功能十分有限,例如:开发一个服务器程序,这个服务器程序需要向不同的客户端提供服务,不同的客户之间应该互不干扰,否则这个程序将不会被接收。
    单线程程序只有一个顺序执行流,多线程则可以包括多个顺序执行流,多个线程之间互不干扰。

多线程程序的优点:

  1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
  2. 提高计算机系统CPU的利用率
  3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改

何时需要多线程:

  1. 程序需要同时执行两个或多个任务。
  2. 程序需要实现一些需要等待的任务时,如用户输入、文件读写操作、网络操作、搜索等。
  3. 需要一些后台运行的程序时

三、线程的创建

    在了解了线程的概念之后,我们在本节来创建线程。Java中使用Thread类表示线程,每一个线程都必须是Thread类的对象或其子类的对象。每个线程对象对应一定的任务,这些任务往往是需要被同时执行的。

    Java中一共提供了四种创建线程的方式。分别是:继承Tread类,实现Runnable接口,实现Callable接口,使用线程池。其中,后两种是JDK5新增的创建线程的方式。

注意:使用者四种方式创建多线程时,自己多加思考,体会其中的异同。

3.1 继承Thread类

多线程的创建:方法一:继承Thread类

步骤:

  1. 创建一个继承于Thread类的子类
  2. 重写Thread类的run方法–>将此线程执行的操作声明在run()中。
  3. 创建子类对象
  4. 通过子类对象调用start()方法:

注意:
  ① 需要通过对象来启动线程。
  ② start方法会自动调用当前线程的run方法。不能直接调用run方法。
  ③ 不能让已经start的线程再去执行,会报异常。需要再去创建一个线程对象,通过这个对象再start。

下面这个例子创建两个线程:
  ① 一个线程用来输出偶数
  创建一个继承自Thread的类,在类中重写run()方法,run()方法中写在线程被调度是执行的操作,比如:这里我们在run()方法内部输出100以内的偶数。
  ② 一个线程用来输出奇数
  我们在main方法中使用匿名内部类的方式创建一个线程,用来输出100以内的奇数,和上面的基本一致,在类中重写run()方法,在run()方法内部写需要执行的操作。
  

程序清单如下

class Test extends Thread{
    @Override
    public void run()
    {
        for(int i=0;i<100;i++)
        {
        //判断是偶数
            if(i%2==0)
            {
                System.out.println("偶数:"+i);
            }
        }
    }
}
public class Demo1 {
    public static void main(String[] args)
{
    //创建一个输出偶数的线程
        Test test = new Test();
        //启动该线程
        test.start();
        Thread.currentThread().setName("主线程");//给main程序命名

       //创建Thread类的匿名子类
        new Thread()
        {
            @Override
            public void run()
            {
                for(int i=0;i<100;i++)
                {
                    //判断是奇数
                    if(i%2!=0)
                    {
                        System.out.println(Thread.currentThread().getName()+"匿名奇数:"+i);
                    }
                }
            }
        }.start();
    }
}

Thread类的有关方法:

① 启动线程,并执行对象的run()方法
void start();

② run()方法需要被重写,线程在被调度时执行的操作
void run();

③ 返回线程的名称
String getName();

④ 设置该线程名称
void setName(String name);

⑤ 返回当前线程。
static Thread currentThread();

⑥ 线程让步,释放CPU使用权,有可能在释放之后有被分配到使用权。
static void yield();

  1. 暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程
  2. 若队列中没有同优先级的线程,忽略此方法

⑦ 线程阻塞。
join();

  1. 在当线程a中的调用线程b的join(),此时线程a就进入阻塞状态,直到线程b完全执行完毕,线程才结束阻塞,继续执行。
    比如:线程a的运行过程中需要一个参数,这个参数需要线程b提供,这时,就需要join()方法。

⑧ 让当前线程睡眠(指定时间:毫秒)
static void sleep(long millis);

  1. 令当前活动线程在指定时间段内放弃对CPU控制,使其他线程有机会被执行,时间到后重排队。
  2. 抛出InterruptedException异常。使用try-catch语句处理异常

强制线程生命期结束,不推荐使用。(已经过时了)
stop();

⑩ 判断线程是否还活着,返回boolean
boolean isAlive();

    注意:这些方法暂时不要全部掌握,通过后面的学习,在实践中慢慢使用,就会融会贯通。

3.2 实现Runnable接口

多线程的创建:方法二:实现Runnable接口

步骤:

  1. 创建一个实现了Runnable接口的类
  2. 实现Runnable接口中的方法
  3. 创建实现类的对象
  4. 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
  5. 通过Thread类的对象调用start()方法

和方法一基本类似,创建一个类去实现Runable接口,在这个类中实现run()方法,把需要执行的操作写在run()方法体中。

程序清单如下


class Test3 implements Runnable{
    @Override
    public void run()
    {
        for(int i=0;i<100;i++)
        {
            if(i%2==0)
            {
                System.out.println("偶数:"+i);
            }
        }
    }
}
public class Demo1 {
    public static void main(String[] args)
    {
        Test3 test3 = new Test3();
        Thread t = new Thread(test3);
        t.start();
    }
}

两个创建线程的比较:

    实现Runnable的方式更好一点。因为实现它的类可能还有其他的直接父类,导致不能继承Thread。因为java是单继承的,但是可以同时有继承和实现。实现的方式会默认共享数据。

所以,在开发中,优先使用实现Runnable的方式。

  1. 实现的方式没有类单继承的局限性
  2. 实现的方式更适合来处理多个线程共享数据的情况。

相同点:都需要重写run方法。继承的方式在内部也实现的Runable接口。

3.3 实现Callable接口

多线程的创建:方法三:实现Callable接口

注意:此方法为JDK5.0新增的创建线程的方式。

步骤:

  1. 创建一个实现callable的实现类。
  2. 实现call方法,将此线程需要执行的操作声明在call方法中。类似与前面的run方法。
  3. 创建Callable接口实现类的对象
  4. 将此Callable实现类的对象作为参数传递到FutureTask构造器中,创建FutureTask对象。
  5. 将FutureTask类的序传递到Thread类的构造器中,创建Thread类的对象,通过该对象启动线程
  6. 可以获取Callable实现类的call方法的返回值。(可选,需要返回值就get,不需要就不get)

特点:
  ① 与使用Runnable相比, Callable功能更强大些
  ② 相比run()方法,可以有返回值
  ③ 方法可以抛出异常
  ④ 支持泛型的返回值
  ⑤ 需要借助FutureTask类,比如获取返回结果 。
  ⑥ get方法的返回值即为FutureTask构造器参数Callable实现类对象重写的call方法的返回值。

    如何理解实现Callable接口创建多线程比实现Runnable接口创建多线程的方式更强大?
  ① call()方法可以有返回值。
  ② call()方法可以抛出异常,被外面的操作捕获,然后处理异常
  ③ call()方法支持泛型

在下面例子中,我们创建一个线程来获取100以内所有偶数的和。
  
  首先,我们创建一个类实现Callable接口,并实现call()方法,同样,把该线程需要执行的操作放在call()方法内部。Call()方法会在线程启动时,被自动调用。Call()方法可以理解为一个增强版的run()方法。

程序清单如下


class NewThread implements Callable{
    @Override
    public Object call() throws Exception
    {
        int sum=0;
        for(int i=1;i<=100;i++)
        {
            //判断是偶数
            if(i%2==0)
            {
                sum=sum+i;
            }
        }
        //自动装箱
        return sum;    
}
}
public class ThreadNew {
    public static void main(String[] args)
    {
        NewThread newThread = new NewThread();
        FutureTask futureTask = new FutureTask(newThread);
        Thread t1 = new Thread(futureTask);
        t1.start();
        try
        {
            //get方法只是为了返回call方法的返回值。
            //get方法自动调用newThread类对象的call()方法
            Object sum = futureTask.get();
            System.out.println(sum);
        } catch (InterruptedException e)
        {
            e.printStackTrace();
        } catch (ExecutionException e)
        {
            e.printStackTrace();
        }
    }
}

3.4 使用线程池

在这里插入图片描述

背景:
  经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。
  比如,我们开发一个类似美团外卖的APP,用户在浏览页面时,需要在加载店家信息的同时加载美食的图片,这时就需要使用多线程,一个线程用来加载文字信息,一个线程用来加载图片。
  这时,饥肠辘辘的用户就有可能浏览页面很快,如果使用以上三种方式来创建线程,就会发生,图片信息和文字信息加载不同步的问题,因为线程的创建也需要一定的开销。这会大大影响我们软件的体验感。

解决思路:
 提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具,我们需要时就能直接使用,而不用等到它创建出来。

使用线程池的好处:
 ① 提高响应速度(减少了创建新线程的时间)
 ② 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
 ③ 便于线程管理
   corePoolSize:核心池的大小
   maximumPoolSize:最大线程数
   keepAliveTime:线程没有任务时最多保持多长时间后会终止

步骤:

  1. 提供指定实数量的线程池
  2. 执行指定的线程的操作,需要提供实现Runnable接口或Callable接口的实现类对象
  3. 将线程对象提交到线程池中
  4. 关闭线程池

例:
  在下面例子中,我们使用实现Runnable接口的方式创建两个线程,并把这两个线程加入到线程池中,一个线程用来输出偶数,一个线程用来输出奇数。
  先创建一个容量为10线程池,把我们创建的两个线程加入到线程池中,加入到线程池中的线程会被自动调用,线程执行结束之后,该线程并不会死亡,而是将该线程返还给线程池以备下次使用。
  

程序清单如下


class NewThread5 implements Runnable{
    @Override
    public void run()
    {
        for(int i=0;i<100;i++)
        {
            if(i%2==0)
            {
                System.out.println(Thread.currentThread().getName()+"偶数:"+i);
            }
        }
    }
}
class NewThread6 implements Runnable{
    @Override
    public void run()
    {
        for(int i=0;i<100;i++)
        {
           //判断是奇数
            if(i%2!=0)
            {
                System.out.println(Thread.currentThread().getName()+"奇数:"+i);
            }
        }
    }
}
public class NewThread4 {
    public static void main(String[] args)
    {
        //service是线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        NewThread5 newThread5 = new NewThread5();
        NewThread6 newThread6 = new NewThread6();
        //输出偶数的线程
        //适合于Runable
        service.execute(newThread5);
        //输出奇数的线程
        service.execute(newThread6);
        //适合Callab
        //  service.submit()
        //关闭线程池
        service.shutdown();

    }
}

四、线程的调度

    本节,我们将理解线程的调度问题。计算机通常只有一个cpu,而在任意时刻只能执行一条指令,每个线程只有获得cpu的使用权才能执行指令。而我们有需要使用多线程,那么计算机是如何实现的呢?

4.1 线程调度的理解

    所谓多线程的并发运行,其实是从宏观上看。在计算机内部,各个线程轮流获取cpu的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待cpu,java虚拟机的一项任务就是负责线程的调度。
线程调度是指按照特定机制为多个线程分配CPU的使用。

调度策略:
  ① 时间片:同优先级线程组成先进先出队列(先到先服务),使用时间片策略
在这里插入图片描述
  ② 对高优先级,使用优先调度的抢占式策略

线程的优先级:
MAX_PRIORITY:10
MIN _PRIORITY:1
NORM_PRIORITY:5

方法:

  1. 返回线程优先值
    getPriority();

  2. 改变线程的优先级。
    setPriority(int p);

例:设置为最大优先级
Thread.currentThread().setPriority(Thread.MAX_PRIORITY);

说明:
  高优先级的程序要抢占低优先级CPU的执行权,但只是从概率上讲,高有优先级的程序高概率被执行。并不意味着只有当高优先级执行完之后才执行低优先级。

4.2 线程的分类

线程的分类
  Java中的线程分为两类:
    1. 一种是守护线程。
    2. 一种是用户线程。

它们在几乎每个方面都是相同的,唯一的区别是判断JVM何时离开。

  1. 守护线程是用来服务用户线程的,通过在start()方法前调用thread.setDaemon(true)可以把一个用户线程变成一个守护线程。 Java垃圾回收就是一个典型的守护线程。
  2. 前台线程死亡后,JVM会通知后台线程死亡,但从它接收到指令到做出相应,需要一定的时间。如果要将某个线程设置为后台线程,就是必须在该线程启动之前设置,即,设置必须在start()方法调用之前。否则会报异常。
  3. 若JVM中都是守护线程,当前JVM将退出

五、线程的生命周期

    人有生老病死,线程也和人一样,同样具有生命周期。线程的生命周期包含五种状态,分别是:新建,就绪,运行,阻塞,死亡。通过上面几节的学习,我们知道了如何创建线程,以及线程的调度问题。本节,我们将看到一个线程的生老病死。

5.1 新建和就绪

线程的新建和就绪就类似我们人类的出生和大学毕业。

  1. 新建:当我们使用new关键字创建了一个线程之后,该线程就处于新建状态,此时,它和普通java对象一样,有JVM分配内存,仅仅是一个具有特殊名字的java对象。
  2. 就绪:当线程对象调用start()方法后,该线程就就处于就绪状态,但它不会立即运行,它会等待CPU的调度,然后才开始运行,当一个对象处于就绪状态时,只表示它当前可以运行了。

注意:
    就绪状态是使用的start()方法,不是直接调用的run()方法,如果直接调用run()方法,那它仅仅是一个普通对象调用run()方法,并没有启动线程。

程序清单如下


class Test3 implements Runnable{
    @Override
    public void run()
    {
        for(int i=0;i<100;i++)
        {
            if(i%2==0)
            {
                System.out.println("偶数:"+i);
            }
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        Test3 test3 = new Test3();
        Thread t = new Thread(test3);
        //没有启动线程,系统只会把它当做一个普通对象。
        t.run();


}
}

    通过上面的实例,我们可以看到,启动一个线程的正确方法是调用线程对象的start()方法,该方法会自动调用run()方法。如果直接调用线程对象的run()方法,系统就只会吧它当做一个普通对象,这时,多线程就失去的它存在的意义,当前程序就会变成一个单线程程序。

5.2 运行和阻塞状态

    线程的运行状态,就恰好对应我们大学毕业找到了一份好工作,在勤勤恳恳的努力着。

    线程的阻塞状态,对应到我们身上就是失业的时候,这时候,就需要重新进入就绪状态,比如我们学习了新知识,然后再去找工作,线程就比较懒了,它只是在等CPU的重新调度或其他线程的帮助。

  1. 一个处于就绪状态的线程,在获得CPU的执行权时,开始执行它的run()方法,这时,该线程就处于运行状态。然而一个线程不可能一直处于运行状态,CPU会执行线程调度,在每个线程之间轮换执行。
  2. 当正在执行的线程,有其他线程调度进来时,该线程就处于阻塞状态。

当发生如下情况时,线程会进入阻塞状态。
  ① 线程调用sleep()方法时,表示当前线程主动放弃CPU的执行权
  ② 当前的同步监视器被其他线程获得,该线程只能处于阻塞状态。
  ③ 当前线程在等待某个线程的notify().
对于以上情况,在阻塞结束时,该线程会重新进入就绪状态。
  ① sleep()指定时间已过
  ② 线程成功获得了同步监视器
  ③ 线程得到了其他线程的通知。

5.3 线程死亡

线程在以下情况下会死亡:
  ① run()方法或call()方法执行完毕
  ② 线程执行过程中调用的stop()方法,强制让该线程死亡,但该方法容易发生死锁。
  ③ 线程捕获了一个异常。

    使用isAlive()方法检测一个线程是否死亡,当线程处于新建和死亡两种状态时,该方法返回false,当线程处于就绪、运行、阻塞状态时,该方法返回true。

    注意:不要对已经死亡的线程调用start()方法使其复活,这是不可能的。

在这里插入图片描述

六、线程同步

    在单线程中,每次只完成一个任务,当然没有什么安全问题。就像一个人专心致志做一件事情时,就很少发生做错或忘了某步骤。而在一心多用时,就有很大的几率发生做错或做的很不完美的的事情。同样,在多线程中,也存在这样类似的问题 — 数据共享。

6.1 为什么要线程同步

    我们通过一个例子来引如线程同步的问题,现在,我们设计一个程序,模拟车站卖票,为了贴近真实生活,我们创建三个窗口同时卖票,然而又恰逢春运期间,只有一百张票。

我们使用继承Thread类和实现Runnable接口的两种方式来创建窗口线程。

一、使用继承Thread类创建线程

程序清单如下


class Window extends Thread{
    //票数 只有100张
    private int ticket=100;
    @Override
    public void run()
    {
        while (true)
        {
            //当前票数大于0,就卖票
            if(ticket>0)
            {
                System.out.println(Thread.currentThread().getName()+":"+"卖票,票号为:"+ticket);
                ticket--;
            }
            else {
                break;
            }
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //new了三个Windowd对象,表示三个窗口
        Window w1 = new Window();
        Window w2 = new Window();
        Window w3 = new Window();
        //设置三个窗口线程的名字
        w1.setName("窗口一");
        w2.setName("窗口二");
        w3.setName("窗口三");
        //启动三个窗口线程,开始卖票
        w1.start();
        w2.start();
        w3.start();
    }
}

运行结果如下

    这是程序运行结果的部分截图,我们可以看到,会出现重票的情况,这就情况肯定是不允许发生的。
    为什么会出现这种情况,读者可能会发现,我们上面new了三个Window线程对象,是不是每个线程对象都持有100张票呢?我们继续看下面这个例子,它只new出一个Window对象。

二、实现Runnable接口创建多线程

程序清单如下

class Window2 implements Runnable{
    private int ticket=100;
    @Override
    public void run(){
        while (true)
        {
            //票数大于0,则卖票
            if(ticket>0){
                System.out.println(Thread.currentThread().getName()+":"+"卖票,票号为:"+ticket);
                ticket--;
            }
            else {
                break;
            }
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //因为只new了一个Window对象,所有共用一个ticket
        Window2 w2 = new Window2();
        //创建三个线程
        Thread t1 = new Thread(w2);
        Thread t2 = new Thread(w2);
        Thread t3 = new Thread(w2);
        //设置线程名
        t1.setName("窗口一");
        t2.setName("窗口二");
        t3.setName("窗口三");
        //启动线程,开始卖票
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下

    从这个运行结果看,好像大部分的重票问题解决了,但是还是会有三张100的票,这显然也是不行的。
    至此,我们可以得出,在使用多线程处理问题时,特别是有共享数据时。会发生线程安全问题。

我们分析出现线程安全的原因:
  当某个线程操作车票的过程中,尚未完成操作,其线程也参与进来操作车票,这就导致了重票问题的发生

解决思路:
  当一个线程A在操作车票时,其他线程不能参与进来,当A操作完成后,其他线程再操作。即使线程A出现阻塞,也不能改变。

Java中通过线程同步来解决线程安全问题。
线程同步有三种方法,分别是:
  ① 同步代码块
  ② 同步方法
  ③ Lock锁

6.2 同步代码块

    在上面模拟窗口卖票的程序中,由于run()中的卖票行为可以同时被多个线程执行,导致了线程安全问题。现在,我们限制对卖票行为的访问,同一时间只允许一个线程对其进行操作。我们可以使用同步代码块的方法,实现这个限制。

格式:

synchronized(obj){
      //需要被同步的代码,即:操作共享数据的代码
}

    这段代码的含义是,在执行对共享数据的操作时,要首先获得同步监视器,简称:锁。
    而且同一时间只能有一个线程获得同步监视器,其他没有获得同步监视器的线程则处于阻塞状态,直到该线程释放同步监视器,其他线程才可以获得同步监视器,进而对共享数据进行操作。

理解两个概念:共享数据和同步监视器
  ① 共享数据:多个线程共同操作的变量,例如:上面程序中的车票ticket。
  ② 同步监视器:俗称“锁”。一个监视器就相当于一扇门,里面锁着的是共享的资源,每次只能有一个人能进入,并且只能容纳一个人,也就是说只有一个线程能获得这个锁。

同步监视器的要求:
  ① 任何一个类的对象都可以充当一个锁。
  ② 多个线程共用同一个锁。
  ③ 推荐使用共享数据充当锁。

注意:
  ① 必须确保使用同一个资源的多个线程共用一把锁,这个非常重要,否则就无法保证共享资源的安全
  ② 一个线程类中的所有静态方法共用同一把锁(类名.class),所有非静态方法共用同一把锁(this),this的使用需谨慎,确保是同一个对象才可以。
  ③ 在实现Runnable接口创建多线程的方式中,我们可以使用this充当锁来代替手动new一个对象,因为后面我们只创建了一个线程的对象。
  ④ 在继承Thread类创建多线程的方式中,慎用this,考虑我们的this是不是唯一的。

同步的优缺点:

  1. 优点:解决了线程安全问题
  2. 缺点;操作同步代码时,只能有一个线程参与,其他线程等待,相当于单线程。

同步的范围:

  1. 如何找问题,即代码是否存在线程安全?(非常重要)
      ① 明确哪些代码是多线程运行的代码。
      ② 明确多个线程是否有共享数据。
      ③ 明确多线程运行代码中是否有多条语句操作共享数据
  2. 如何解决呢?(非常重要)
      对多条操作共享数据的语句,只能让一个线程都执行完,在执行过程中,其他线程不可以参与执行。即:所有操作共享数据的这些语句都要放在同步范围中
  3. 切记:
      ① 范围太小:没锁住所有有安全问题的代码
      ② 范围太大:没发挥多线程的功能

现在,我们使用两种方式来解决上面卖票程序中出现的线程安全问题。

方法一:实现Runnable接口创建多线程解决线程安全问题:

程序清单如下

class Window2 implements Runnable{
    private int ticket=100;
    @Override
    public void run()
    {
        while (true)
        {
            //同步代码块,因为我们创建了三个线程,所以不能使用this作同步监视器
            synchronized(Window2.class)
            {
                //票数大于0,则卖票
                if (ticket > 0)
                {
                    try
                    {
                        //是当前线程sleep100毫秒,体现线程的等待
                        Thread.sleep(100);
                    } catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ":" + "卖票,票号为:" + ticket);
                    ticket--;
                } else
                {
                    break;
                }
            }
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //创建一个窗口对象
        Window2 w1 = new Window2();
        //创建三个线程
        Thread t1 = new Thread(w1);
        Thread t2 = new Thread(w1);
        Thread t3 = new Thread(w1);
        //设置线程名字
        t1.setName("窗口一");
        t2.setName("窗口二");
        t3.setName("窗口三");
        //启动线程
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果如下

我们看到,现在,已经没有重票的问题出现了。

方法二:继承Thread类创建多线程使用同步代码块解决问题的代码:

程序清单如下


class Window extends Thread{
    //使用static修饰保证,保证上线程共用ticket
    private  static int ticket=100;
    @Override
    public void run()
    {
        while (true)
        {
            synchronized (Window.class)
            {
                //票数大于0,则卖票
                if (ticket > 0)
                {
                    try
                    {
                        sleep(100);
                    } catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ":" + "卖票,票号为:" + ticket);
                    ticket--;
                } else
                {
                    break;
                }
            }
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //创建三个线程
        Window w1 = new Window();
        Window w2 = new Window();
        Window w3 = new Window();
        //设置线程名字
        w1.setName("窗口一");
        w2.setName("窗口二");
        w3.setName("窗口三");
        //启动线程
        w1.start();
        w2.start();
        w3.start();
    }
}

运行结果如下

我们看到。线程安全问题同样被解决了。

6.3 同步方法

    与同步代码块相对应的,是同步方法。使用synchronized关键字修饰操作共享数据的的方法。该方法就被称为同步方法。即:如果操作共享数据的代码完整的声明在一个方法中,我们可以将这个方法声明为同步的。

同步方法仍然有同步监视器,只是不需要我们显示的声明。
  ① 非静态同步方法,同步监视器:this
  ② 静态同步方法,同步监视器:当前类本身

我们使用同步方法来解决懒汉式创建单例对象的线程安全问题。

程序清单如下

class Bank{
    //无参构造器
    private Bank()
    {}
    //缓存单例类的对象
    private static Bank instance=null;
    //同步方法
    public static synchronized Bank getInstance()
    {
        //表示还没有创建单例对象
        if(instance==null)
        {
            instance = new Bank();
        }
        return instance;

    }
}

6.4 Lock锁

    从JDK 5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当,每次只能有一个线程对Lock对象加锁,线程开始操作共享数据之前,要先获得Lock对象。

    ReentrantLock 类实现了 Lock ,它拥有与 synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。

    使用ReentrantLock对象来充当锁进行同步时,我们建议把释放锁的操作放在finally语句块中,确保一定会释放锁,防止死锁的出现。
Lock锁也遵循“加锁–修改–释放锁”的逻辑。

我们同样以卖车票的例子来说明。

程序清单如下


class Window5 implements Runnable{
    private int ticket = 100;

    //1. 实例化一个ReentrantLock对象
    //默认参数是false,写为true之后,表示是公平的锁
    private ReentrantLock lock = new ReentrantLock();
    @Override
    public void run()
    {
        while (true)
        {
            //2. 调用锁定方法。lock方法。获得锁
            lock.lock();
            try
            {
                //票数大于0 则卖票
                if(ticket>0)
                {
                    try
                    {
                        Thread.sleep(100);
                    } catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ":" + "卖票,票号为:" + ticket);
                    ticket--;
                }
                else
                {
                    break;
                }
            }finally
            {
                //3. 解锁
                lock.unlock();
            }


        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //创建窗口对象
        Window5 w5 = new Window5();
        //创建三个线程
        Thread t1 = new Thread(w5);
        Thread t2 = new Thread(w5);
        Thread t3 = new Thread(w5);
        //设置线程名字
        t1.setName("窗口一:");
        t2.setName("窗口二:");
        t3.setName("窗口三:");
        //启动三个线程
        t1.start();
        t2.start();
        t3.start();
    }
}

6.5 对比三种方式

synchronized 与 Lock 的对比:

  1. Lock是显式锁(手动开启和关闭锁,别忘记关闭锁),synchronized是隐式锁,出了作用域自动释放
  2. Lock只有代码块锁,synchronized有代码块锁和方法锁
  3. 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)

优先使用顺序:
  Lock --> 同步代码块(已经进入了方法体,分配了相应资源)–> 同步方法(在方法体之外)

在同步代码块和同步方法中,何时会释放对同步监视器的锁定

  1. 当前线程的同步方法或同步代码块执行完毕时,自动释放对同步监视器的锁定。
  2. 当前线程中抛出的了异常,当前代码块被停止运行,同步监视器被释放。
  3. 当前线程中的同步方法或同步代码块中遇到了break、return,则同步监视器被释放。
  4. 当前线程的同步方法或同步代码块中执行遇到了wait()方法时,释放同步监视器。

以下情况不会释放同步监视器

  1. 当前线程执行时,遇到sleep()方法或yield()方法时,只是暂停了当前线程,并不会释放同步监视器。
  2. 当前线程使用了suspend()方法将当前线程挂起,这时,不会释放同步监视器。这种方法容易导致线程死锁,不推荐使用。

总结:
  解决线程安全问题,有几种方式?
    ① synchronized
      同步代码块
      同步方法
    ② Lock

我们来看一个典型例题,来加深对线程同步的理解
  例题:银行有一个账户。有两个储户分别向同一个账户存3000元,每次存1000,存3次。每次存完打印账户余额。

问题分析:
  ① 是不是多线程问题?
    肯定是,两个线程,分别是两个储户
  ② 是否有共享数据?
    有,账户
  ③ 所以,存在线程安全问题。
    使用同步机制解决

程序清单如下


class Account{
    //余额
    private double balance;
    //使用继承的方式创建多线程,需要加static,保证是一把锁
    private static ReentrantLock lock = new ReentrantLock();
    public Account(double balance)
    {
        this.balance=balance;
    }
    //方法一:使用synchronized的同步方法
    //  public synchronized void deposit(double amt)
    //方法二:使用lock
    //存款
    public void deposit(double amt)
    {
        //加锁
        lock.lock();
        try
        {
            //存款金额大于0,则存款
            if(amt>0)
            {
                try
                {
                    Thread.sleep(1000);
                } catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
                balance=balance+amt;
                System.out.println(Thread.currentThread().getName()+":"+"存钱成功,余额为:"+balance);
            }
        }
        finally
        {
            //释放锁
            lock.unlock();
        }
    }


}

/**
 * 为了演示这个程序可以使用this作为同步监视器,使用继承的方式创建多线程。
 */
class Customer extends Thread{
    //账户
    private Account account;
    public Customer(Account account)
    {
        this.account=account;
    }

    @Override
    public void run()
    {
        for(int i=0;i<3;i++)
        {
            //存款1000
            account.deposit(1000);
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //创建一个账户
        Account account = new Account(0);

        Customer customer1 = new Customer(account);
        Customer customer2 = new Customer(account);

        customer1.setName("甲");
        customer2.setName("乙");
        customer1.start();
        customer2.start();
    }
}

运行结果如下

注意:
  推荐使用实现的方式创建多线程。本程序为了演示可以使用this作为同步监视器,使用继承的方式创建多线程。
  采用继承的方式创建多线程,使用同步方法时,慎用this,这个题可以使用this,是因为this是唯一的,是同一个Account。

6.6 线程死锁

什么是线程死锁,举个很形象的例子。
  你和你的好朋友去吃饭,餐桌上有一盘十分美味的佳肴,你和你的朋友都想吃,但是只有一双筷子,你和你的朋友一人抢到一只筷子,你们都不愿意放弃这顿美味佳肴,都在等对方放弃筷子,这时,你们便一直僵持着。这就是死锁。
  在这里插入图片描述
    不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁
出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续类似与死循环。

下面的程序就演示了线程死锁。

程序清单如下

public class Lock {
    public static void main(String[] args)
    {
        StringBuffer s1 = new StringBuffer();
        StringBuffer s2 = new StringBuffer();
        //继承方式创建匿名多线程
        new Thread()
        {
            @Override
            public void run()
            {
                synchronized (s1)
                {
                    s1.append("A");
                    s2.append("1");
                    try
                    {
                        //加大死锁概率
                        Thread.sleep(100);
                    } catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                    synchronized (s2)
                    {
                        s1.append("B");
                        s2.append("2");
                        System.out.println(s1);
                        System.out.println(s2);
                    }
                }
            }
        }.start();
        //实现方式创建匿名多线程
        new Thread(new Runnable() {
            @Override
            public void run()
            {
                synchronized (s2)
                {
                    s1.append("C");
                    s2.append("3");
                    synchronized (s1)
                    {
                        s1.append("D");
                        s2.append("4");
                        System.out.println(s1);
                        System.out.println(s2);
                    }
                }
            }
        }).start();
    }
}

    这个程序运行时,不会出错,也不会报异常,就是一直僵持着,除非我们手动停掉程序的运行。

    我们的程序中不应该出现线程死锁的问题,我们可以通过下面几种常见的方法来避免线程死锁。

  1. 使用定时锁:使用Lock对象加锁是,可以指定当前线程的锁定时间,超过该时间后,当前线程的同步监视器被自动释放。
  2. 避免多次锁定:在编写程序时,要避免同一个线程对多个同步监视器的锁定。
  3. 尽量避免嵌套同步:在一个线程的同步中,尽量避免对另外一个线程的同步。
  4. 使用相同的加锁顺序;如果需要多个线程对多个同步监视器进行锁定时,尽量保证他们具有相同的加锁顺序。

七、线程通信

    当执行多线程程序时,CPU会在多个线程之间进行调度,而调度具有一定的随机性,我们无法在程序中准确控制线程之间的轮换执行。
    比如,我们开发时,需要多个模块之间的协调,A模块需要B模块给出一个参数,这时,在执行A模块时,就必须转去执行B模块,这就是线程之间的通信。
本小节通过例题来说明线程之间的通信。

7.1 常用方法

线程通信中的三个常用方法:wait(); 、 notify();、notifyAll();

  1. wait();是调用其的线程进入阻塞状态。并释放锁。
  2. notify();唤醒被阻塞的线程。如果有多个线程,就唤醒优先级高的那个。
  3. notifyAll();唤醒所有被阻塞的线程。

注意:

  1. 上面三个方法只能出现在同步方法或同步代码块中。不能在lock中。
  2. 这三个方法是定义在Object类中。
  3. 这三个方法的调用者必须是同步方法或同步代码块的同步监视器(锁),否则,会出现非法监视器的错误。

以下程序会出现非法监视器的错误

程序清单如下


class Number implements Runnable{
    private int number=1;
private ReentrantLock lock = new ReentrantLock();
//创建一个对象充当同步监视器
    Object object = new Object();
    @Override
    public void run()
    {
        while (true)
        {
           synchronized (object)
           {
               //唤醒一个线程,默认是this调用。
                notify();
                if(number<101)
                {
                    System.out.println(Thread.currentThread().getName()+"打印:"+number);
                    number++;
                    try
                    {
                       //使得调用如下wait方法的线程进入阻塞状态,wait会释放锁
                        wait();
                    } catch (InterruptedException e)
                    {
                        e.printStackTrace();
                    }
                }
                else
                {
                    break;
                }
            }
        }
    }
}

    在这个程序中,我们new了一个Object类的对象充当同步监视器,在同步代码块中调用notifyAll()非法,默认通过this调用,而这里的同步监视器是Object对象,出现了不一致,所以会报非法同步监视器异常。

7.2 sleep()和wait()

sleep()方法和wait()方法的异同:

  1. 相同点:都可以让当前线程进入阻塞状态。
  2. 不同点:
    1. 两个方法声明的方法不一样,Thread类中声明sleep(),Object类中声明wait()。
    2. 调用的范围不一样。sleep()方法可以在任意需要的位置调用。wait()方法必须使用在同步代码块或同步方法中。
    3. 如果两个方法都使用在他代码块或同步方法中:sleep()方法不会释放锁,wait()方法会释放锁。

我们通过一个典型的例题来理解线程的通信

例:
  生产者和消费者的问题:生产者(Productor)将产品交给店员(Clerk),而消费者(Customer)从店员处取走产品,店员一次只能持有固定数量的产品(比如:20),如果生产者试图生产更多的产品,店员会叫生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。
  在这里插入图片描述

这里可能出现两个问题:

  1. 生产者比消费者快时,消费者会漏掉一些数据没有取到。
  2. 消费者比生产者快时,消费者会取相同的数据。

问题分析:
  ① 是否是多线程?
    是,生产者线程,消费者线
  ② 共享数据是什么?
    店员(产品)
  ③ 如何解决线程安全问题?
   同步机制,三种方法
   线程的通信

程序清单如下

//店员
class Clerk{
    private int amount=0;
    //生产产品
    public synchronized void  producePorduct()//同步方法
    {
        //产品小于20时,生产者开始生产产品
        if(amount<20){
            amount++;
            System.out.println(Thread.currentThread().getName()+":开始生产第"+amount+"个产品");
            notify();
        }
        //产品大于20时,生产者停止生产,等待消费者消费
        else{
            //等待
            try
            {
                wait();
            } catch (InterruptedException e)
            {
                e.printStackTrace();
            }
        }
    }
    //消费产品
    public synchronized void consumeProduct()
    {
        //当产品数大于0时,即:当前有产品时,消费者开始消费产品
        if(amount>0)
        {
            System.out.println(Thread.currentThread().getName()+":开始消费第"+amount+"个产品");
            amount--;
            notify();
        }
        //当前已经没有产品时,消费者等待生产者生产
        else
        {
            //等待
            try
            {
                wait();
            } catch (InterruptedException e)
            {
                e.printStackTrace();
            }
        }
    }
}
//生产者线程
class Producer implements Runnable{
    //店员对象
    private Clerk clerk;
    public Producer(Clerk clerk)
    {
        this.clerk=clerk;
    }
    @Override
    public void run()
    {
        System.out.println(Thread.currentThread().getName()+"开始生产……");
        while (true)
        {
            try
            {
                Thread.sleep(100);
            } catch (InterruptedException e)
            {
                e.printStackTrace();
            }
            clerk.producePorduct();//生产者生产产品
        }
    }
}
//消费者线程
class Consumer implements Runnable{
    private Clerk clerk;
    public Consumer(Clerk clerk)
    {
        this.clerk=clerk;
    }
    @Override
    public void run()
    {
        System.out.println(Thread.currentThread().getName()+"开始消费……");
        while(true){
            try {     
                Thread.sleep(100);
            } catch (InterruptedException e)
            {
                e.printStackTrace();
            }
            //消费者消费产品
            clerk.consumeProduct();
        }
    }
}
public class Test {
    public static void main(String[] args)
    {
        //创建一个店员对象
        Clerk clerk = new Clerk();
        //创建生产者对象
        Producer p1 = new Producer(clerk);
        //创建消费者对象
        Consumer c1 = new Consumer(clerk);
        //创建生产者线程
        Thread t1 = new Thread(p1);
        t1.setName("生产者");
        //创建消费者线程
        Thread t2 = new Thread(c1);
        t2.setName("消费者");
        //启动线程
        t1.start();
        t2.start();
    }
}

运行结果如下

至此,小李使用“多线程”心法,修炼多年,终成一代宗师。就让我们江湖见。
在这里插入图片描述

发布了75 篇原创文章 · 获赞 376 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/qq_44755403/article/details/105514594