java - 深入篇 --Java的多线程实现

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/dengminghli/article/details/73252407

前面我们讲了java的异常处理机制,介绍了在java开发中是如何定位错误,即显见bug的,而今天我们要探究的是一个老司机开车的问题–多线程,关于这个问题,整个体系很是复杂,同时也是面试中必考的一个考点,最重要的是,如果没有掌握到这个知识点,那么你在接下来的学习中,会觉得非常的痛苦。所以,在这里将额外花费一些事件进行介绍。(敲黑板,笔记做好咯)

多线程概念

在介绍什么叫多进程前,请允许先介绍一下进程和线程的关系。所谓进程,就是我们调用程序的这么一个过程,通俗来说,就是从我们打开应用,到最后关闭应用的这么一个过程,在这个过程里,计算机会经历从代码加载到内存,代码执行和执行完毕,程序退出,内存空间被销毁。这个整体的状态,称之为程序的进程。而其中,代码按照顺序不断执行的过程,就是一个线程。而何为多进程呢?我们知道,程序运行的时候,是会按照代码顺序(循环,选择,顺序)一行一行去实现的是吧,那么,在这里就牵涉到了一个问题:“如果我们要实现从网上加载一张比较大的图片到手机上并且显示,会是怎么样的呢?”如果从单线程的角度来说,当我们要加载图片的时候,肯定是要先等它加载完了才可以执行下一步是吧。那么如果这张图要加载一个小时呢?在这个过程里我们的用户可不能再执行其他的操作,就只能眼巴巴地盼着时间快点过去了是吧,但这是不存在的,一般来说,如果我们的软件要客户登上一个小时什么都不能动,客户只会默默卸载而不是等待。所以,我们必须想一个办法,既要让用户最终加载到这张图,同时也不会说只能等待而不能执行其他的操作。怎么办呢?没错,这就要用到我们的多线程技术了。所谓多线程技术,你可以理解为同时进程开挂,创造出了好几个工人(线程),让这些工人各自负责一些指定的内容,而不是只有一个工人来干完一件又一件。也就是说,所谓多线程,就是指可以同时运行多个程序块,使得程序运行的速率得更高。

实现多线程

要实现多线程,从整体上而言,主要分为两种方式,分别是继承Thread类或者实现Runnable接口,下面让我们一起看看详细的介绍

继承Thread类

Thread类存放在java.lang包中,当一个子类继承了Thread类时,必须实现其自带的run()方法,在其中编写需要实现的功能代码,然后创建出该对象,调用start方法执行。格式如下:

class ClassName extends Thread{
    public void run(){
        //code
    }
}

ClassName test = new ClassName();
test.start();

我们来看一下具体实例:

package testabstractclass;

public class Test1 {
    public static void main(String[] args){
        ThreadTest1 test1 = new ThreadTest1();
        ThreadTest2 test2 = new ThreadTest2();
        test1.start();
        test2.start();
        }
}
/**
 *测试线程1
 */
class ThreadTest1 extends Thread{
    int i=0;
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("测试线程1-->" + i);
        }

    }

}
/**
 *测试线程2
 */
class ThreadTest2 extends Thread{
    int i=0;
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("测试线程2-->" + i);
        }
    }

}

运行,结果如图:
这里写图片描述
在这里,你会发现,尽管test1和test2打印的代码的顺讯并不是连续的,但是也还是一个一个地打印出来,按道理来说,那也还是顺序执行是吧?是的,在我们的多线程中,程序的执行也是有顺序的,那么什么才是决定顺序的标志呢?就是CPU资源,我们的CPU在程序运行的时候会产生空闲的CPU资源,一旦哪个线程抢先获得了这个资源,就可以执行它的代码。所以,在多线程中,抢资源是非常关键的,这也说明了为什么运行结果会有线程1和线程2交互执行。当然,如果我们再次执行的时候,就会发现,其实他的结果也是会变得。因为每一次执行的时候,我们都不知道哪个线程会先取得CPU资源,所以每一次的运行结果都是未知的。但不可否认的是,这样的话,真的实现了我们一边加载图片,一边执行其他操作的愿景。当然,问题也会产生,聪明的我们即将一步一步探究怎么解决这些问题。慢慢来,不要急。
注意:一个线程对象只能调用一次的start方法,如果重复调用会出现IllegalThreadStateException异常,切记切记

实现Runnable接口

如果我们查看过Thread类的源码(没有查看的现在可以去查),你会发现,其实Thread是直接继承自一个Runnable的接口来实现多线程的。也就是可以理解为:Thread是在Runnable接口的基础上进行封装的一个类。我们可以使用Thread来实现多线程,自然也可以使用Runnale来实现多线程。怎么实现呢?我们看一下基本格式:

class className implements Runnable{

    public void run(){
        //执行多线程的代码
    }
}
具体的实例如下:
class ThreadTest1 implements Runnable{

    @Override
    public void run() {
        for (int i = 0;i<10;i++) {
            System.out.println("thread1->"+i);
        }
    }
}

看到这里,大家可能就觉得和Thread没什么区别了,但是该怎么启动这个多线程呢?我们上面是用了Thread.start()方法来调用的是吧。那么我们的Runnable又是怎么调用的呢?熟悉我的套路的朋友们可能就想到说:我们来看一下源码,是吧。但实际上,在这里,是没有源码可以给你参考的,为什么呢?我们来看一下源码(哈哈哈,绕回来了),但是如果你们真的去看了Runnable的源码,你会发现,它就只有一个run方法,就这么简单粗暴了。连个启动的方法都没有,所以,我们要怎么才能启动Runnable呢?先看一段示例:
这里写图片描述
眼尖的朋友们可能发现了其中的关键所在,这里有两句代码:

new Thread(new ThreadTest1()).start();
new Thread(new ThreadTest2()).start();

是不是看起来特别无离头?如果是的话,说明匿内部类还没学到家,需要回去补补知识哦,在这里,我们是通过匿名内部类来实现了一个Thread对象,并且调用了这个对象的start方法。但是这个对象呢,和我们在上面看到的Thread又有点区别,就是他不需要重写run方法,而把这个run方法的实现交给了runnable。就好比Thread是一把枪,runnable是炮弹。即便我们是在Thread内部重写的run方法,本质上也还是runnable,因为Thread实现了Runnable接口。所以,姑且可以理解为:Runnable是基础,Thread是拓展。而由此便引发了这两者的一个区别所在:
如果一个类继承自Thread,则不适合多个线程共享资源,而如果一个类实现了Runnable接口,则可以在多个线程中去使用,从而实现了共享资源
就好比你有一支可以自动产生炮弹的枪,你可以随时打出这一发子弹,但是你不能做到把这发子弹放在其他的枪上使用。而我用工厂制作出来的标准子弹,因为没有限制在你的枪上,所以可以随便给其他的枪使用。所以,实现Runnable接口的好处就在于:

1.适合多个执行相同代码的线程去处理统一资源
2.避免由于java的单继承特性带来的局限
3.代码与数据独立分开,增强程序健壮性

如果想要亲自看一下效果的同学,可以尝试自己写一个售票程序。模拟3台机器同时出售50张票。分别用Thread和Runnable的方式来实现一次,或许你对这两种方式的区别就会有一个更为深刻的理解了。但是具体的操作步骤,这里便不去说,最后的学习办法除了跟着敲,还要想着敲。自己思考一下执行步骤应该是怎样的,再去写自己的代码,更容易帮助你理解和记忆。

拓展:解密多线程背后的启动方式

我们知道,我们前面是用了Thread.start()方法来实现多线程,但细心的我们应该可以发现,我们调用了这个start方法之后,执行的却是run方法,为什么呢?这是因为我们虽然是调用了start来启动了一个多线程,但这时的多线程是并没有执行,而是出于一种就绪的状态,在这个状态,一旦系统获得了cpu资源,便开始执行run方法。注意,这里的run方法也有一个坑,他执行的并不是Thread里面的run方法,而是Runnable里面的run方法,什么意思呢?我们看看源码:

 /* What will be run. */
    private Runnable target;

/**
     * If this thread was constructed using a separate
     * <code>Runnable</code> run object, then that
     * <code>Runnable</code> object's <code>run</code> method is called;
     * otherwise, this method does nothing and returns.
     * <p>
     * Subclasses of <code>Thread</code> should override this method.
     *
     * @see     #start()
     * @see     #stop()
     * @see     #Thread(ThreadGroup, Runnable, String)
     */
    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }

我们从源码发现,尽管我们最终调用了Thread的run方法,但实际上,调用的却是target中的run方法。也就是说,我们看起来是调用了Thread的start方法,但实际上最终执行的却是Runnable中的run方法。是不是很晕?但晕也得记住,这也是一道面试题来的。

线程的状态

我们前面说了,当我们调用start方法后,线程会进入一个就绪的状态,那么由此便牵涉到了下面的内容,线程有什么状态呢?我们通过一张图来看看:
这里写图片描述
虽然丑了点,但大致长这样,接下来我们针对这张图做一个说明:

创建状态:当我们构造出一个线程对象的时候,此时这个对象便处于创建状态,拥有着相应的内存空间以及资源,但是却无法运行

就绪状态:如上所说,当我们进调用了start方法时,便进入这个状态,但此时同样不能运行,因为它缺少了cpu时间片

运行状态:当现场对象获得了cpu时间片时,便开始进入执行状态(执行run方法)。一旦执行完毕,便终止当前线程。一旦还没有执行完毕,便失去了cpu时间片(其他阻塞时间发生时),会进入阻塞状态,或者返回就绪状态,等待下一次获得cpu时间片时,继续运行

阻塞状态:一个正在执行的线程在某种特殊的情况下,比如人为的挂起(调用sleep()、wait()、suspend()等方法)或者需要执行耗时的操作时,线程会让出cpu并暂时停止执行,进入阻塞状态。在这个状态下,线程不能进入排队队列,只有当引起阻塞的原因消除后,才会重新转入就绪状态

结束状态:线程调用stop方法或者run方法全部执行完毕后,便处于结束状态,这个状态下的线程不具有重新运行的能力,等待被回收。

线程安全的问题

谈到多线程,必不可少的一个问题就是线程安全的问题。在开发过程中,如果我们通过多线程操作统一组数组,那么因为多个线程是同时执行的,所以在这个过程中便极为出现数据丢失或者数据不准确的问题。什么意思呢?比如我们通过两个线程分别操作放东西和拿东西,一个线程负责把货物放到车上,另一个线程便负责把货物拉走。并且他们是同时工作的。假设刚开始的时候,放东西的线程还跟得上拿东西的线程的速度,但后来,体力不支,跟不上拿东西的线程的速度时,便存在了这么一个问题,我们的货物还没放到车上,拿东西的线程就已经把车给拉走了。如此便造成了数据丢失。再比如售票的问题,我们假设当票大于0的时候,就继续把票卖出,而小于或者等于0的时候,就不卖了是吧。但是有可能存在这么一种情况,我们的线程一刚监测到票额还有1张,准备买下把票数减1,代表卖到了这一张票的时候,突然失去了cpu时间片,这时候,线程二也监测到了这张票的信息,因为线程一还没有减1,所以此时票数仍然显示是1,所以这个时候线程二觉得,恩,还有票,然后准备执行见一操作时,坑爹的cpu时间片又没了,此时线程三一路杀入,看到还有一张票,二话不说,买下了,此时票数显示为0了是吧。这一切看起来无非就是线程三运气足够好是吧,但是如果对线程状态还有印象的同学可能会醒悟过来,这里面有坑啊!设想,当线程一在此获得时间片的时候,它会执行什么样的步骤呢?再监测有没有票?太天真了,它说,我之前已经监测过了,肯定还有票的,不怕,于是刷刷刷地把票额减一,就走了,走了。。。看都不看票数还剩多少。再然后,线程二又醒了,同样说我之前监测到了还有1张票,不怕,于是再把票数减一,又走了。但是坑爹的是,最后一张票明明已经被线程三拿走了啊!所以这里线程一和线程二拿到的又是什么鬼呢?所以这里就是数据不准确的情况了。那么,就买票而言,如果12306没有处理好这种线程不安全的问题,一个春节课后,可能就要被买到票又做不了车的人给砸了吧,毕竟车位真的有限呀,那么没怎么处理这个问题呢?这就又涉及到接下来要介绍的内容了。那就是——

同步与死锁

先说同步,同步是一种用来处理线程不安全的技术,是指多个操作在同一个时间段内只能有一个线程进行,其他线程要等待这个线程结束后才可以继续执行。什么意思呢?我们把数据空间当做一间房子,当线程一走进这个房子里开始操作数据后,便把门给锁住,不让其他的线程进来。不管线程一在里面睡了多久才拿到cpu时间片,只要它不全部执行完毕,离开这个房子,其他的线程就不能进来。当然,也有一种情况是,线程一处理完这个数据后,第二次进来的也还是它,因为它可以再次获得cpu时间片,进而在此进行数据操作。但不管怎么样都好,我们都避免了上面说的被线程三捷足先登的问题了。因此也就保证了线程的安全。那么,怎么实现同步呢?有两种方式:

使用同步代码块

使用同步代码块的时候需要了解一个关键字:synchronize,如果我们在常见的代码块上加上这个关键字,就表明它是一个同步代码块,格式如下:

synchronize (同步对象){
    需要同步的代码
}

就以买票为例,请看:

public class ThreadDemo1 {
    public static void main(String[] args) {
        new Thread(new ThreadTest1("线程一")).start();
        new Thread(new ThreadTest1("线程二")).start();
        new Thread(new ThreadTest1("线程三")).start();
    }
}

    class ThreadTest1 implements Runnable{
        private String name;
        private static int ticket = 5;
        public ThreadTest1(String name) {
            this.name = name;
        }


        @Override
        public void run() {
            for (int i = 0;i<10;i++) {
                synchronized (ThreadDemo1.class) {
                    if (ticket >0) {
                        try {
                            Thread.sleep(300);
                        } catch (InterruptedException e) {
                            // TODO Auto-generated catch block
                            e.printStackTrace();
                        }
                        System.out.println(name+"卖出一张票,当前票额:"+ticket--);
                    }
                }
            }
        }
    }

运行这段代码,结果如下:
这里写图片描述
在这里,如果你多运行几遍,你就会发现,前面的线程或许会变,但最后的票数肯定就是按照这样的顺序来执行,这就是同步带来的好处,避免了线程不安全的问题发生。
那么,在这里,或许也有人奇怪,说在这里:

synchronize (同步对象){
    需要同步的代码
}

这里的同步对象应该是什么?关于这个问题,一般而言,我们是把当前能够获得该对象中需要同步的数据的对象。什么意思呢?就如上面示例而言,我们锁住的对象是:

synchronized (ThreadDemo1.class) {
                if (ticket >0) {
                    try {
                        Thread.sleep(300);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(name+"卖出一张票,当前票额:"+ticket--);
                }
            }

可以看到,我们锁住的是ThreadDemo1而不是实现了Runnable的ThreadTest1对象呢,这是因为,我们在ThreadDemo创建出了三个不同的线程,每个线程都继承了Runnable接口,但是每个线程对象的地址
是否就是一致的呢?不是的。他们是分别独立的三个对象,所以当我们锁住的是ThreadTest1对象时,那就代表我们最终只是分别锁住了三个不同的对象,这样的话我们不是在同一个对象里面进行操作。就好比我们在卖票的地方开了三个窗口,但是只有一个窗口是只能同步的,那么剩下的窗口自然就是谁有空就谁去执行操作,这样的话还能实现同步吗?不能,所以我们要把范围扩大,把这个卖票的地方锁起来,每次只允许一个线程进入,不管他选择哪个窗口都没关系,只要保证是每次一个线程在操作就好。

同步方法

同步方法的使用比同步块看起来简单,它只需要在方法中添加synchronized声明即可。这里不多做介绍,直接上示例代码:

public class ThreadDemo1 {
    public static void main(String[] args) {
        ThreadTest1 threadTest1 = new ThreadTest1("售票机");
        new Thread(threadTest1).start();
        new Thread(threadTest1).start();
        new Thread(threadTest1).start();
    }
}

class ThreadTest1 implements Runnable{
    private String name;
    private int ticket = 5;
    public ThreadTest1(String name) {
        this.name = name;
    }
    @Override
    public void run() {
        for (int i = 0;i<10;i++) {
            this.sale();
        }
    }


    private synchronized void sale() {
        if (ticket > 0) {
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name+"卖出一张票,当前票额:"+ticket--);

        }
    }
}

这个示例代码看起来和上面那一个差不多,实际上却存在着极大的差别。不仅体现在使用方法上,还体现在使用方式,数据处理,对象选择等方面都有差别。因此希望朋友们多研究一下这两个代码有何不同之处,当你能找出这两段代码之间的差别时,就表明你对同步有一个比较深入的理解了。但是在这里,并不会对此进行讲解吗,而希望是你们自己去领悟,如此才能提升你们的思考。当然,如果你思考出来的话,也可以在下方评论贴出,让大家伙参考参考,看看你的理解有没有跑偏了,毕竟及时的学习反馈才是最重要的嘛。
你们以为学习就要结束了吗?非也。我们只讲了同步,还没开始讲死锁呢,怎么可以结束了呢?接下来再看我们的另一个知识点——“死锁
什么叫死锁呢?所谓的死锁就是指两个线程都在等待对方先完成,造成了程序之间的停滞状态,这是由于同步过多引起的。什么意思呢?假设我们目前有两个数据需要同步,线程一的名字叫张三,它需要接收线程二(李四)手中的画之后,才可以把自己的书传送给李四。这看起来没什么问题,当李四把画给了张三之后,张三就把书交给李四,很符合逻辑对不对?那我们接下来再加个条件,李四说:张三要先把书给李四,李四才可以把画交给张三。那么,问题就发生了。双方都需要对方的东西,双方有需要对象先提交同样的东西才可以做出响应。而且张三和李四知识程序,他不会自主协调说,我先给你一半,你也先给我一半吧,所以问题就发生了,张三不断请求李四给他画,李四不断请求张三给他书。就好像先有鸡后有蛋,还有先有蛋,后有鸡的问题一样,不断重复,因为便造成了死锁。那么要怎么解决这个问题呢?其一,就是要编码前确定好逻辑顺序,先给谁,再给谁。其二,就是尽量减少同步了。

多线程综合案例:生产者与消费者的瓜葛

关于多线程,存在的忧虑无非就是数据丢失或者数据精度缺失的问题(至少我目前遇到的是这些,如果还有其他,也欢迎补充,不要让我做井底之蛙呀,拜谢~),我们在前面就说了当多个线程操作同一数据时,线程安全问题就必须解决。就好比生产者与消费者的关系,生产者生产产品,消费者消费产品,两者似乎没什么瓜葛是吧,但是需要注意的是,如果生产者没有生产产品,消费者又何来的消费呢?因此,这里便产生了一个模式“生产者-消费者模式”。它的思路大致如下:当生产者生产出产品后,通知消费者去拿。当生产者在生产产品时,消费者进入等待状态,知道生产者叫它之后,再去消费产品。实例代码如下:

public class ThreadDemo1 {
    private List<Integer> number = new ArrayList<Integer>(10);
    public static void main(String[] args) {
        ThreadDemo1 marKet = new ThreadDemo1();
        Consumer consumer = marKet.new Consumer();
        Producer producer = marKet.new Producer();
        new Thread(producer).start();
        new Thread(consumer).start();

    }
    /**
     * 生产者
     * @author dml
     *
     */
    class Producer implements Runnable{

        @Override
        public void run() {
            while(true){
                synchronized(number){
                    while(number.size() == 10){
                        try {
                            System.out.println("生产空间已满,通知消费");
                            number.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            number.notify();
                        }
                    }
                    number.add(1);
                    System.out.println("生产一个商品,当前还能生产"+(10-number.size())+"个商品");
                }
            }
        }
    }

    /**
     * 消费者
     * @author dml
     *
     */
    class Consumer implements Runnable{

        @Override
        public void run() {
            while(true){
                synchronized (number) {
                    while(number.size() ==0){
                        try {
                            System.out.println("没有商品可消费,等待中");
                            number.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                            number.notify();
                        }
                    }
                    number.remove(0);
                    number.notify();
                    System.out.println("消费一个,剩余"+number.size()+"个商品");
                }
            }


        }

    }
}

运行结果如下:
这里写图片描述
好了,关于生产者,消费者的实现,不做讲解,让大家去思考。但是接下来要普及的几个东西,是要记住(可能会被面试),也是辅助你理解的,不要错过了哦

关于等待与唤醒那些事

在线程中使线程进入等待(阻塞状态),有几个方法,其中常用的是sleep()和wait();其中sleep方法是线程类的方法,作用是暂停该线程的执行时间,把执行机会让给其他线程,到制定时间后便会自动回复。调用sleep方法不会释放对象锁。因此不能用于同步
wait方法是Object类的方法,即所有对象原则上都能调用这个方法。当该对象调用wait方法时,会导致本线程放弃对象锁,而进入等待对象池中,只有针对这个对象发出的notify或者notifyall方法后,本线程才进入对象锁定池准备获得对象锁,进而进入运行状态。

线程池那些事

关于线程,还有一个重要的概念,叫做线程池。为什么会有这个东西呢?我们来分析一下,当我们创建出一个线程对象时,是不是就已经为其分配好了一定的内存空间,当线程执行结束后,便进入结束状态呢?这点毋庸置疑,我们在前面已经说到,那么,如果我们要创建多个线程,是不是要给每个线程都分配一定的内存空间呢?是的,这就有可能导致这样一个问题,我们的内存空间不断分配,而失去作用的线程对象又还没被及时回收,如此便容易造成内存溢出(OOM)而导致程序崩溃。因此,我们再使用线程的时候,一定不能多开线程,要限制他的数量。可是我又可要这么多线程,怎么办呢?线程池就出现了,它的作用是封装几个线程在里面,当我们调用一个线程池时,会把里面的线程取出,执行线程任务,执行完毕后,回到线程池中休眠,直到下一次的线程任务调用。如此,便解决了需要创建大量的线程对象的问题,用于多线程下载中是非常试用的。那么,如何创建线程池呢?这个不用你担心,一般而言,很多框架都会为我们内置好线程池,我们不用手动去创建、但有时候遇到面试的时候,有的面试官会问你这个问题,如果你能写得出的话,无疑又是一项加分项,关于这点,因为篇幅有限(眼睛受不住了~~),所以这里不作示例,贴出几个干货连接,希望可以帮助到您~
传送门出现!
深入理解java之线程池
海子的这篇文章,真的是非常详细地介绍了关于线程池的知识。如果能看一遍下来,或许你就大概晓得怎么实现线程池了。
最后,关于多线程的一些知识点就要结束了。距离我们正式进入Android方面的介绍也不远了。但同样的问题在于,本人的期末考试准备周也到来了。因此,总结起来就是6月上旬忙着“挑战杯”,下旬忙着复习考试。所以博客这里又要冷落一段时间了,估计下旬还会发一篇关于单例模式的面试知识点,其他的内容可就要等七月份啦。预估在七月份,我们就可以正式踏入Android开发的过程啦,加油吧,骚年们。
官方声明:
如果对文章有表示疑问之处,请在下方评论指出,共同进步,谢谢~

展开阅读全文

没有更多推荐了,返回首页