暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

线程基础概念(下)

Alleria Windrunner 2020-04-23
191

线程的start方法剖析:模版设计模式在Thread中的应用

在本节中,我们将分析 Thread 的 start 方法,在调用了 start 方法之后到底进行了什么操作,通过1.3节的内容讲解,相信大家已经明白了,start 方法启动了一个线程,并且该线程进入了可执行状态(RUNNABLE),在“TryConcurrency”中,我们重写了 Thread 的 run 方法,但却调用了 start 方法,那么 run 方法和 start 方法有什么关系呢?带着诸多的疑问,我们一起在本节中寻找答案吧!


Thread start 方法源码分析以及注意事项

先来看一下 Thread start 方法的源码,如下所示:
    public synchronized void start() {
    if (threadStatus != 0)
    throw new IllegalThreadStateException();
    group.add(this);


    boolean started = false;
    try {
    start0();
    started = true;
    } finally {
    try {
    if (!started) {
    group.threadStartFailed(this);
    }
    } catch (Throwable ignore) {
    }
    }
    }


    start 方法的源码足够简单,其实最核心的部分是 start0 这个本地方法,也就是 JNI 方法:
      private native void start0();
      也就是说在 start 方法中会调用 start0 方法,那么重写的那个 run 方法何时被调用了呢?单从上面是看不出来任何端倪的,但是打开 JDK 的官方文档,在 start 方法中有如下的注释说明:
        ※ Causes this thread to begin execution; the Java Virtual Machine calls the <code>run</code> method of this thread.
        上面这句话的意思是:在开始执行这个线程时,JVM 将会调用该线程的 run 方法,换言之,run 方法是被 JNI 方法 start0() 调用的,仔细阅读 start 的源码将会总结出如下几个知识要点。
        • Thread 被构造后的 NEW 状态,事实上 threadStatus 这个内部属性为0。

        • 不能两次启动 Thread,否则就会出现 IllegalThreadStateException 异常。

        • 线程启动后将会被加入到一个 ThreadGroup 中,后文中我们将详细介绍 ThreadGroup。

        • 一个线程生命周期结束,也就是到了 TERMINATED 状态,再次调用 start 方法是不允许的,也就是说 TERMINATED 状态是没有办法回到 RUNNABLE/RUNNING 状态的。


          Thread thread = new Thread()
          {
          @Override
          public void run()
          {
          try
          {
          TimeUnit.SECONDS.sleep(10);
          } catch (InterruptedException e)
          {
          e.printStackTrace();
          }
          }
          };
          thread.start();//启动线程


          thread.start();//再次启动

          执行上面的代码将会抛出 IllegalThreadStateException 异常,而我们将代码稍作改动,模拟一个线程生命周期的结束,再次启动看看会发生什么:
            Thread thread = new Thread()
            {
            @Override
            public void run()
            {
            try
            {
            TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e)
            {
            e.printStackTrace();
            }
            }
            };
            thread.start();
            TimeUnit.SECONDS.sleep(2);//休眠主要是确保 thread 结束生命周期
            thread.start();//企图重新激活该线程
            程序同样会抛出 IllegalThreadStateException 异常,但是这两个异常的抛出却有本质上的区别,第一个是重复启动,只是第二次启动是不允许的,但是此时该线程是处于运行状态的,而第二次企图重新激活也抛出了非法状态的异常,但是此时没有线程,因为该线程的生命周期已经被终结。


            模板设计模式在 Thread 中的应用

            通过上节的分析,我们不难看出,线程的真正的执行逻辑是在 run 方法中,通常我们会把 run 方法称为线程的执行单元,这也就回答了我们最开始提出的疑问,重写 run 方法,用 start 方法启动线程。Thread 中 run 方法的代码如下,如果我们没有使用 Runnable 接口对其进行构造,则可以认为 Thread 的 run 方法本身就是一个空的实现:
              @Override
              public void run() {
              if (target != null) {//我们并没有使用 runnable 构造 Thread
              target.run();
              }
              }
              其实 Thread 的 run 和 start 就是一个比较典型的模板设计模式,父类编写算法结构代码,子类实现逻辑细节,下面通过一个简单的例子来看一下模板设计模式,然后读者可以参考该模式在 Thread 中的使用,示例代码如清单1-2所示。
                public class TemplateMethod {


                public final void print(String message) {
                System.out.println("################");
                wrapPrint(message);
                System.out.println("################");
                }


                protected void wrapPrint(String message) {


                }


                public static void main(String[] args) {
                TemplateMethod t1 = new TemplateMethod(){
                @Override
                protected void wrapPrint(String message) {
                System.out.println("*"+message+"*");
                }
                };
                t1.print("Hello Thread");


                TemplateMethod t2 = new TemplateMethod(){
                @Override
                protected void wrapPrint(String message) {
                System.out.println("+"+message+"+");
                }
                };


                t2.print("Hello Thread");


                }
                }


                Thread 模拟营业大厅叫号机程序

                相信很多人都去过银行、医院、移动营业厅、公积金中心等,在这些机构的营业大厅都有排队等号的机制,这种机制的主要作用就是限流,减轻业务受理人员的压力。当你走进营业大厅后,需要先领取一张流水号纸票,然后拿着纸票坐在休息区等待你的号码显示在业务办理的橱窗显示器上面,如图1-3所示。
                如图所示,假设大厅共有四台出号机,这就意味着有四个线程在工作,下面我们用程序模拟一下叫号的过程,约定当天最多受理50笔业务,也就是说号码最多可以出到50。
                TicketWindow 代表大厅里的出号机器,代码如清单1-3所示。
                  public class TicketWindow extends Thread {
                  //柜台名称
                  private final String name;


                  //最多受理50笔业务
                  private static final int MAX = 50;
                  private int index = 1;


                  public TicketWindow(String name) {
                  this.name = name;
                  }


                  @Override
                  public void run() {
                  while (index <= MAX) {


                  System.out.println("柜台:" + name + "当前的号码是:" + (index++));
                  }
                  }
                  }
                  接下来,写一个 main 函数,对其进行测试,定义了四个 TicketWindow 线程,并且分别启动:
                    public static void main(String[] args) {
                    TicketWindow ticketWindow1 = new TicketWindow("一号出号机");
                    ticketWindow1.start();


                    TicketWindow ticketWindow2 = new TicketWindow("二号出号机");
                    ticketWindow2.start();


                    TicketWindow ticketWindow3 = new TicketWindow("三号出号机");
                    ticketWindow3.start();


                    TicketWindow ticketWindow4 = new TicketWindow("四号出号机");
                    ticketWindow4.start();


                     }
                    运行之后的输出似乎令人大失所望,为何每一个 TickWindow 所出的号码都是从1到50呢?
                    之所以出现这个问题,根本原因是因为每一个线程的逻辑执行单元都不一样,我们新建了四个 Ticket Window 线程,它们的票号都是从0开始到50结束,四个线程并没有像图1-3所描述的那样均从客席号服务器进行交互,获取一个唯一的递增的号码,那么应该如何改进呢?无论 TicketWindow 被实例化多少次,只需要保证 index 是唯一的即可,我们会立即会想到使用 static 去修饰 index 以达到目的,改进后的代码如清单1-4所示:
                      public class TicketWindow extends Thread {


                      private final String name;


                      private static final int MAX = 50;


                      private static int index = 1;


                      public TicketWindow(String name) {
                      this.name = name;
                      }


                      @Override
                      public void run() {
                      while (index <= MAX) {
                      System.out.println("柜台:" + name + "当前的号码是:" + (index++));
                      }
                      }
                      }
                      再次运行上面的 main 函数,会发现情况似乎有些改善,四个出号机交替着输出不同的号码,输出如下:
                      通过对 index 进行 static 修饰,做到了多线程下共享资源的唯一性,看起来似乎满足了我们的需求(事实上,如果将最大号码调整到500、1000等稍微大一些的数字就会出现线程安全的问题,关于这点将在后面的章节中详细介绍),但是只有一个 index 共享资源,如果共享资源很多呢?共享资源要经过一些比较复杂的计算呢?不可能都使用 static 修饰,而且 static 修饰的变量生命周期很长,所以 Java 提供了一个接口 Runnable 专门用于解决该问题,将线程的控制和业务逻辑的运行彻底分离开来。


                      Runable接口的引入以及策略模式在Thread中的使用

                      Runnable 的职责

                      Runnable 接口非常简单,只定义了一个无参数无返回值的 run 方法,具体如代码清单所示。
                        public interface Runnable {
                        void run();
                        }
                        在很多软文以及一些书籍中,经常会提到,创建线程有两种方式,第一种是构造一个 Thread,第二种是实现 Runnable 接口,这种说法是错误的,最起码是不严谨的,在 JDK 中代表线程的就只有 Thread 这个类,我们在前面分析过,线程的执行单元就是 run 方法,你可以通过继承 Thread 然后重写 run 方法实现自己的业务逻辑,也可以实现 Runnable 接口实现自己的业务逻辑,代码如下:
                          @Override
                          public void run() {
                          //如果构造 Thread 时传递了 Runnable,则会执行 runnable 的 run 方法
                          if (target != null) {
                          target.run();
                          }
                          //否则需要重写 Thread 类的 run 方法
                          }
                          上面的代码段是 Thread run 方法的源码,我在其中加了两行注释更加清晰地说明了实现执行单元的两种方式,所以说创建线程有两种方式,一种是创建一个 Thread,一种是实现 Runnable 接口,这种说法是不严谨的。准确地讲,创建线程只有一种方式那就是构造 Thread 类,而实现线程的执行单元则有两种方式,第一种是重写 Thread 的 run 方法,第二种是实现 Runnable 接口的 run 方法,并且将 Runnable 实例用作构造 Thread 的参数。




                          策略模式在 Thread 中的应用

                          前面说过了,无论是 Runnable 的 run 方法,还是 Thread 类本身的 run 方法(事实上 Thread 类也是实现了 Runnable 接口)都是想将线程的控制本身和业务逻辑的运行分离开来,达到职责分明、功能单一的原则,这一点与 GoF 设计模式中的策略设计模式很相似,在本节中,我们一起来看看什么是策略模式,然后再来对比 Thread 和 Runnable 两者之间的关系。
                          相信很多人都做过关于 JDBC 的开发,下面我们在这里做一个简单的查询操作,只不过是把数据的封装部分抽取成一个策略接口,代码如清单1-6所示。
                            import java.sql.ResultSet;


                            public interface RowHandler<T>
                            {


                            T handle(ResultSet rs);
                            }
                            RowHandler 接口只负责对从数据库中查询出来的结果集进行操作,至于最终返回成什么样的数据结构,那就需要你自己去实现,类似于 Runnable 接口,示例代码如清单所示。
                              import java.sql.Connection;
                              import java.sql.PreparedStatement;
                              import java.sql.ResultSet;
                              import java.sql.SQLException;


                              public class RecordQuery
                              {


                              private final Connection connection;


                              public RecordQuery(Connection connection)
                              {
                              this.connection = connection;
                              }


                              public <T> T query(RowHandler<T> handler, String sql, Object... params)
                              throws SQLException
                              {
                              try (PreparedStatement stmt = connection.prepareStatement(sql))
                              {
                              int index = 1;
                              for (Object param : params)
                              {
                              stmt.setObject(index++, param);
                              }


                              ResultSet resultSet = stmt.executeQuery();
                              return handler.handle(resultSet);//①调用RowHandler
                              }
                              }
                              }
                              RecordQuery 中的 query 只负责将数据查询出来,然后调用 RowHandler 进行数据封装,至于将其封装成什么数据结构,那就得看你自己怎么处理了,下面我们来看看这样做有什么好处?
                              上面这段代码的好处是可以用 query 方法应对任何数据库的查询,返回结果的不同只会因为你传入 RowHandler 的不同而不同,同样 RecordQuery 只负责数据的获取,而 RowHandler 则负责数据的加工,职责分明,每个类均功能单一,相信通过这个简单的示例大家应该能够清楚 Thread 和 Runnable 之间的关系了。
                              重写 Thread 类的 run 方法和实现 Runnable 接口的 run 方法还有一个很重要的不同,那就是 Thread 类的 run 方法是不能共享的,也就是说 A 线程不能把 B 线程的 run 方法当作自己的执行单元,而使用 Runnable 接口则很容易就能实现这一点,使用同一个 Runnable 的实例构造不同的 Thread 实例。


                              模拟营业大厅叫号机程序

                              既然我们说使用 static 修饰 index 这个共享资源不是一种好的方式,那么我们在本节中使用 Runnable 接口来实现逻辑执行单元重构一下1.4节中的营业大厅叫号机程序。
                              首先我们将 Thread 的 run 方法抽取成一个 Runnable 接口的实现,代码如清单1-8所示。
                                public class TicketWindowRunnable implements Runnable {


                                private int index = 1;//不做 static 修饰


                                private final static int MAX = 50;


                                @Override
                                public void run() {


                                while (index <= MAX) {
                                System.out.println(Thread.currentThread() + " 的号码是:" + (index++));
                                try {
                                Thread.sleep(100);
                                } catch (InterruptedException e) {
                                e.printStackTrace();
                                }
                                }
                                }
                                }
                                可以看到上面的代码中并没有对 index 进行 static 的修饰,并且我们也将 Thread 中 run 的代码逻辑抽取到了 Runnable 的一个实现中,下面的代码构造了四个叫号机的线程,并且开始工作:
                                  public static void main(String[] args) {


                                  final TicketWindowRunnable task = new TicketWindowRunnable();


                                  Thread windowThread1 = new Thread(task, "一号窗口");


                                  Thread windowThread2 = new Thread(task, "二号窗口");


                                  Thread windowThread3 = new Thread(task, "三号窗口");


                                  Thread windowThread4 = new Thread(task, "四号窗口");


                                  windowThread1.start();
                                  windowThread2.start();
                                  windowThread3.start();
                                  windowThread4.start();
                                  }
                                  程序的输出与之后的代码清单的输出效果是一样的,四个叫号机线程,使用了同一个 Runnable 接口,这样它们的资源就是共享的,不会再出现每一个叫号机都从1打印到50这样的情况。
                                  不管是用 static 修饰 index 还是用实现 Runnable 接口的方式,这两个程序多运行几次或者 MAX 的值从50增加到500、1000或者更大都会出现一个号码出现两次的情况,也会出现某个号码根本不会出现的情况,更会出现超过最大值的情况,这是因为共享资源造成的。
                                  文章转载自Alleria Windrunner,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                                  评论