阿里专家与你分享:你必须了解的Java多线程技术

摘要:本文介绍了Lambda表达式的起源以及基本语法,并提供代码实例帮助大家理解Lambda表达式的使用。另外,本文介绍了Java开发中常用的多线程技术,详细介绍多线程涉及到的概念以及使用方法。

数十款阿里云产品限时折扣中,赶紧点击这里,领劵开始云上实践吧!

演讲嘉宾简介:
吕德庆(花名:嵛山),阿里巴巴高级开发工程师,武汉大学地信硕士,有丰富的系统开发经验,使用过Java,C++、Go、Python、Javascript、.Net等多种语言,目前主要精力在Java,就职于阿里巴巴代码中心团队,负责后端开发。

PPT地址:https://yq.aliyun.com/download/2657
以下内容根据演讲嘉宾视频分享以及PPT整理而成。

本次的分享主要围绕以下两个方面:
一、Lambda入门
二、多线程技术

一、Lambda入门
Lambda起源于数学中的λ演算中的一个匿名函数,从它的起源我们可以知道,Lambda本身就是一个匿名函数,是Java8才推出的亮点,体现了函数式编程的思想。现在主流的编程语言都包含了函数式编程的特性,Java8在进化过程中吸收了该特性,作为面向编程对象的补充。
Lambda基本语法如下图所示,Lambda语法较为简单,和普通函数相比,没有返回值以及函数名,它的参数和执行语句之间通过->连接,表示参数将传递到语句中执行。Lambda表达式还有两种简化表达式的方法,当表达式中只有一个执行语句时,可以省略语句的{};如果接口的抽象方法只有一个形参,()可以省略,只需要参数的名称即可。Lambda可以替代特定匿名内部类,Lambda表达式不能单独存在,在使用时必须继承函数式接口。
下图示例中的第一个Lambda表达式,形参列表的数据类型会自动推断,只需要参数名称。
 阿里专家与你分享:你必须了解的Java多线程技术

代码示例:
package lambda;

public class Lambda {

    public static void main(String[] args) {

        Flyable flyable = new Flyable() {
            @Override
            public void fly(int a) {
                System.out.println("I can fly by anonymous class");
            }

            //@Override
            //public void landing() {
            //    System.out.println("I can landing by anonymous class");
            //}
        };
        flyable.fly(1);

        flyable = (t) -> System.out.println("I can fly by lambda");
        flyable.fly(1);


        Bird bird = new Bird() {
            @Override
            void fly() {
                System.out.println("I can fly by bird");
            }
        };

        bird = () -> System.out.println("I can fly by lambda");

    }

    @FunctionalInterface
    interface  Flyable {
        void fly(int a);
        //void landing();
    }

    abstract static class Bird {
        abstract void fly();
    }
}
在上图展示的代码中,代码中的匿名内部类继承了Flyable接口,实现了接口中的fly()方法。代码准备了Lambda表达式重新实现了Flyable接口。根据代码中的输出命令,执行结果显示Lambda表达式起到了和匿名内部类相同的作用。代码中,并没有定义Lambda表达式的参数类型,但是我们也可以在Lambda表达式中定义符合要求的类型flyable=(int t)->System.out.println(“I can fly by Lambda”),如果参数类型与接口中方法参数类型不一致flyable=(String t)->System.out.println(“I can fly by Lambda”),编译器就会报错。
假如接口实现了两个方法,匿名内部类可以重写新的方法。但是,Lambda表达式没法做到这一点,编译后,将会提示发现有多个需要重写的抽象方法。因此,Lambda表达式在实现接口时,只允许接口中有一个抽象方法,我们将这样的接口称为函数式接口,Java8中提供了注解@FunctionalInterface检验接口是否为函数式接口,如果不是,注解将会报错。另外,代码尝试使用Lambda表达式替代抽象类的匿名内部类的写法,但会报错,提示必须继承函数式接口。因此,Lambda可以替代特定匿名内部类,简化代码,但是必须继承函数式接口。
二、多线程技术
1.进程与线程
进程是具有一定独立功能的程序,关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。线程是进程的一个实体,是CPU分配调度的基本单位,代码的执行体。从概念上,我们可以知道进程是程序的一次运行活动,需要系统进行分配和调度的;线程是最终代码的执行体,是CPU分配调度的基本单位。同一个进程中可以包括多个线程,并且线程共享整个进程的资源,一个进程至少包括一个线程。如果在理解概念时很费解,想要充分理解这些概念,我们可以采用反抽象的方法,即联系,我们需要在实际生活中寻找符合概念描述的事物。举例说明:我们经常说安卓手机比较卡,手机上App跑的太多,导致内存不足,那么我们在手机上看到的这些App,就是一个个程序;在手机卡顿时,双击home键,看到有App在后台运行,这是我们看到的这些app就是进程。进程是需要系统分配资源的,资源相当于手机的内存。通过这个例子,我们可以加深对进程和程序概念上的理解。另外,我们也可以通过反抽象的方法理解进程与线程的概念。举例说明:公司运转与员工工作,这里的公司,我们可以对应到程序;进程是程序的运行活动,这里的进程,我们可以理解为公司的正常运转;同时,公司想要正常运转,离不开员工的工作,员工是公司运转不可分割的实体,只有员工才是真正做事的人,因此我们可以将线程类比员工。
2.线程的生命周期
下图为线程的状态图。所谓的生命周期,指的是线程从出生到死亡过程中,经历的一系列状态。线程通过创建Thread的一个实例new Thread()进入new新建状态;之后调用start()方法进入等待被分配时间片,进入runnable状态;之后,线程获得CPU资源执行任务,进入running状态;当线程执行完毕或被其它线程杀死,线程就进入dead死亡状态;如果由于某种原因导致正在运行的线程让出CPU并暂停自己的执行,即进入blocked堵塞状态,在多种条件下,blocked状态可以恢复成runnable状态,最终在线程重新拿到时间片后,就可以进入running状态重新运行。在running状态下,如果时间片用完了或者线程主动放弃CPU的使用,线程重新回到runnable状态。
时间片指的是CPU的时间片段,CPU将它的可执行时间分成很多片段,每个片段随机分配给处在runnable状态下的线程,这样可以达到并发的效果。假设我有一个单核的CPU,通过分割很多的时间片,每个程序都有机会运行,仍然可以跑很多的程序,宏观上看是并发的,但是由于只有一个CPU,实际上程序还是串行的。
阿里专家与你分享:你必须了解的Java多线程技术 
我们可以通过阅读JDK的Thread类注释,创建并使用线程,如下图所示。
阿里专家与你分享:你必须了解的Java多线程技术 
按照JDK的注释,下述代码中使用了两种创建线程的方法。由于Runnable是一个函数式接口,因此代码中使用Lambda表达式替代匿名内部类,再将runnable传递给Thread,使用start()启动线程。
public class ThreadTest {
    public static void main(String[] args) {
        PrimeThread thread = new PrimeThread();
        thread.setName("Thread ");
        thread.start();

        Runnable runnable = () -> System.out.println(Thread.currentThread().getName() + " runnable run.");
        Thread t = new Thread(runnable);
        t.run();


        System.out.println(Thread.currentThread().getName());

    }

    static class PrimeThread extends Thread {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " Thread run");
        }
    }

}
上述代码结果如下图所示。在下图代码中,如果我们将t.start();替换成t.run(),打印结果将会变成: 
Thread Thread run
Main runnable run.
Main
这说明run()方法并没有真正启动线程,run()方法只是在当前的线程中执行了run中的函数。
阿里专家与你分享:你必须了解的Java多线程技术 
3. 线程协作
并行与协作:线程在并发的过程中更多的是协作关系,就像之前的概念中所提到的,进程是系统资源分配的单位,线程本身并没有多少分配资源,除了维护自己必须的内存开销之外,线程的所有资源都是在进程中。多线程在使用竞争中资源时,存在抢占或者说是共享的关系。
这时,多线程之间该如何协作,是需要我们去解决的。我们通过下面的代码,学会使用关键字synchronized,以及理解临界区,锁的概念。
public class Tickets {

    int tickets = 10;

    /**
     * 重复卖票
     */
    void sell() {
        while (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + " sell ticket:" + tickets);
            tickets--;
        }
        System.out.println(Thread.currentThread().getName() + " sell out.");
    }
    public static void main(String[] args) {
        Tickets tickets = new Tickets();

        Thread sellerA = new Thread(tickets::sell);
        sellerA.setName("sellerA");

        Thread sellerB = new Thread(tickets::sell);
        sellerB.setName("sellerB");

        Thread sellerC = new Thread(tickets::sell);
        sellerC.setName("sellerC");

        sellerA.start();
        sellerB.start();
        sellerC.start();
    }

}
上述代码模拟售票操作。一共有10张票,三个售票员sellerA,seller,sellerC一起去售票,sell( )方法模拟售票行为。代码启动线程之后,运行结果如下图所示。售票员sellerA在一个时间片内将sell方法中的代码全部跑完,票售空,但是sellerB与sellerC在线程并发时,也售出了第10张票,存在重复售票,这样的操作是不合理的。
阿里专家与你分享:你必须了解的Java多线程技术 
为了解决重复售票的问题,我们可以使用Java中提供的同步关键字synchronized修饰sell( )方法,代码如下述所示。使用关键字synchronized修饰后,多线程在访问sell( )方法时,能保证只有一个线程执行这个方法,当前线程执行完sell( )方法后,其他线程才能执行sell( )方法。
/**
     * sync之后,导致独占资源
     */
    synchronized void sell() {
        while (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + " sell ticket:" + tickets);
            tickets--;
        }
        System.out.println(Thread.currentThread().getName() + " sell out.");
    }
执行上述代码后,输出结果如下图所示。从下面结果可以看到,代码解决了重复售票的不合理问题,但是仍然只有sellerA一个在售票。原因在于,通过关键字synchronized修饰sell( )方法后,sellerA在拿到sell( )方法的执行权时,把里面的代码一口气执行完了,也就是将票全部卖出,等sellerA执行完后,sellerB和sellerC再执行sell( )方法时,票数已经为0,自然会出现下图中没有卖出一张票的现象。我们将方法sell( )中的内容叫做临界区,当一个线程进入临界区后,其他线程必须等待该线程执行完临界区内容后,才能进入该临界区。
 阿里专家与你分享:你必须了解的Java多线程技术
下述代码改善了上述sellerA一口气卖完所有票的现象。代码在方法体内使用关键字synchronized,括号中的this表示一个对象或者一个类。代码相较于上面的解决方法,将临界区从整个方法缩小到两行代码。也就是说多线程在执行这两行代码时是同步的。
/**
     * 改善后,资源没有独占
     */
    void sell() {
        while (tickets > 0) {
            synchronized (Tickets.class) {
                System.out.println(Thread.currentThread().getName() + " sell ticket:" + tickets);
                tickets--;
            }
            //do something
            try {
                TimeUnit.MILLISECONDS.sleep(50L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
        System.out.println(Thread.currentThread().getName() + " sell out");
    }
上述代码执行结果如下图所示。从图中我们可以发现,不再是只有sellerA在卖票。并且代码每次执行结果都是不一样的,因为CPU的时间片是随机给出的。上述代码中的try catch方法块使线程睡50ms,延长售票操作的时间,在这段时间内可以执行其他的操作(比如,将该票给某个顾客)。代码改善过后,保证资源不是被独占的,使资源分配均匀。
 阿里专家与你分享:你必须了解的Java多线程技术
从运行结果来看,存在无效票,原因在于:假设当前票数为1,A进入临界区售票,而此时B已经进行判断,在临界区外等待了。当A卖完票后,票数为0,但是B还是会进入临界区进行售票操作,因此,出现无效票-1的情况。这说明代码需要进一步改善。改善后的代码如下所示。代码在临界区内加入判断条件,只有票数大于0时,才会进行售票操作,这是常用的双重检验方法。经过双重检验后,运行代码就不会出现无效售票。 
 /**
     * 改善后,资源没有独占, 修复卖出无效票的问题
     */
    void sell() {
        while (tickets > 0) {
            synchronized (this) {
                if (tickets > 0) {
                    System.out.println(Thread.currentThread().getName() + " sell ticket:" + tickets);
                    tickets--;
                }

            }
            //do something
            try {
                TimeUnit.MILLISECONDS.sleep(50L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
        System.out.println(Thread.currentThread().getName() + " sell out");
    }
下面介绍另外一种单线程同步的方法。代码如下。代码通过Lock接口定义了一个锁,使用ReentrantLock实现。锁和上面提到的关键字synchronized作用是一样的,都是定义出一个临界区,让线程进入临界区时实现线程同步。代码通过lock.lock( )定义临界区的初始点,使用在try语句块中定义临界区执行内容, finally语句块中采用unlock( )方法进行解锁。在unlock后线程才算真正走出临界区。使用try,finally的原因在于:如果try中抛出异常,如果没有finally中的解锁,线程不会调用unlock方法,永远占用这把锁,导致其他线程无法进入临界区执行代码。在finally中调用unlock( )方法保证无论什么情况下,锁终将被释放。避免死锁。
private Lock lock = new ReentrantLock();
    /**
     * 使用锁
     */
    void sell() {
        while (tickets > 0) {
            lock.lock();
            try {
                if (tickets > 0) {
                    System.out.println(Thread.currentThread().getName() + " sell ticket:" + tickets);
                    tickets--;
                }
            } finally {
                lock.unlock(); //锁必须在finally块中释放
            }

            //do something
            try {
                TimeUnit.MILLISECONDS.sleep(50L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
        System.out.println(Thread.currentThread().getName() + " sell out");
    }
在上述展示的代码中,如果线程遇到售卖同一张票,锁没有被释放,线程将会等待。改善这种情况的方法是,我们使用10把锁,使得每张票都有一把锁,当线程A售卖某张票时,其他线程可以跳过这张票,无需等待去卖其他未售出的票。或者,使用两把锁,五张票一把锁,这种分段锁的策略进一步提高了并发的效率。
4. 线程池
线程虽然不占用进程中的资源,但在Java中,如果每当一个请求到达就创建一个新线程,开销是相当大的。并且,如果在一个JVM里创建太多的线程,可能会导致系统由于过度消耗内存导致系统资源不足,为了防止资源不足,应该尽可能减少创建和销毁线程的次数,特别是一些资源耗费比较大的线程的创建和销毁,尽量复用已有对象来进行服务,这就线程池技术产生的原因。如果想要实现线程的复用,我们需要继承线程,在run方法中通过循环不断从外部获取runnable的实现,以此达到线程复用的目的。有了复用后,可以提供线程池,管理线程,线程池可以控制线程的并发度,同时,通过对多个任务重用线程,线程创建的开销就被分摊到了多个任务上了,而且由于在请求到达时线程已经存在,所以消除了线程创建所带来的延迟。
下面介绍一下线程池的使用。下图代码中展示了ThreadPoolExecutor的构造方法,下面介绍一下方法中包含的参数。
  • corePoolSize:表示线程池的核心线程数,指线程池中常驻线程的数量,核心线程数会一直在线程池中存活,除非线程池停止使用被资源回收了。
  • maximumPoolSize:指线程池所能容纳的最大线程数量,当活动线程数到达这个数值后,后续的新任务将会被阻塞。
  • keepAliveTime:非核心线程闲置时的超时时长,超过这个时长,非核心线程就会被回收。当ThreadPoolExecutor的allowCoreThreadTimeOut属性设置为true时,keepAliveTime同样会作用于核心线程。
  • Unit:用于指定keepAliveTime参数的时间单位。
  • workQueue:表示线程池中的任务队列(阻塞队列),通过线程池的execute方法提交Runnable对象会存储在这个队列中。
  • threadFactory:表示线程工厂,为线程池提供创建新线程的功能。
  • RejectExecutionHandler:这个参数表示当ThreadPoolExecutor已经关闭或者已经饱和时(达到了最大线程池大小而且工作队列已经满),提供以下几个策略考虑是否拒绝到达的任务。DiscardPolicy:直接忽略提交的任务
  • AbortPolicy:忽略提交的任务,在拒绝的同时抛出异常,通知调用者拒绝执行
  • CallerRunsPolicy:让线程池的使用者所在的线程运行提交的任务调用者
  • DiscardOlderestPolicy:忽略最早放到队列中的任务
阿里专家与你分享:你必须了解的Java多线程技术
下面代码自定义了一个线程池。通过线程池的submit( )方法提交runnable的实现,最终通过线程池的shutdown( )方法关闭线程池。
 ThreadPoolExecutor threadPoolExecutor =
            new ThreadPoolExecutor(16, 30, 30L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<Runnable>(10), new ThreadFactory() {
                @Override
                public Thread newThread(Runnable r) {

                    Thread t = new Thread(r);

                    t.setDaemon(false);
                    t.setUncaughtExceptionHandler((thread, e) -> System.out.println(e.getMessage()));
                    return t;
                }
            }, new DiscardOldestPolicy());



        threadPoolExecutor.submit(() -> System.out.println(Thread.currentThread().getName()));

        threadPoolExecutor.shutdown();

        //ExecutorService executorService = Executors.newFixedThreadPool();

        findJavaExecutorsBug(); 
Java包中预置的线程池有以下几种:newSingleThreadExecutor;newFixedThreadPool:newCachedThreadPool: newScheduledThreadPool: 但在阿里巴巴的Java开发中是不建议甚至禁止使用Java预置线程池的。下图中的代码目的是寻找SingleThreadExecutor的bug。 
 static void findJavaExecutorsBug() {
       ExecutorService executorService = Executors.newSingleThreadExecutor();

       for (;;) {
           executorService.submit(() -> {
               try {
                   TimeUnit.SECONDS.sleep(30);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           });
       }
    }
上述代码的运行结果如下图所示。代码利用循环,无限添加runnable的实现,但是由于单一线程的阻塞队列是没有边界的,会导致添加的对象过多,耗尽内存资源。因此阿里巴巴开发手册是明确禁止使用Java预置线程池的。
阿里专家与你分享:你必须了解的Java多线程技术 
本文由云栖志愿小组沈金凤整理,编辑百见
上一篇:Leetcode JS刷题


下一篇:工业的最强大脑—ET工业大脑,打通数据,升维“供、研、产、销”