并发编程专题 2:使用多线程编程

1、基础梳理

  1. 进程和线程。1). 进程是操作系统正在执行的不同应用程序的一个实例,线程是操作系统分配处理器时间的基本单元。2). 每个进程运行在自己的地址空间,而线程共享数据内存和 IO 这些资源。
  2. 线程的优缺点优点:1). 程序的运行效率可能会更高;2). 可以使用线程把占用时间较长的任务放在后台去执行;3). 在一些等待耗时任务和交互事件的时候同时可以执行其他任务。缺点:1).如果有大量的线程,会影响性能,因为操作系统需要在它们之间切换;2).更多的线程需要更多的内存空间;3).通常块模型数据是在多个线程间共享的,需要防止线程死锁情况的发生。

2、使用线程

2.1 使用 Thread 创建单线程

Java 中提供了 Thread 类用来创建线程。当我们调用 Thread 的 start() 方法的之后,Thread 的 run() 方法将会被调用。默认的,Thread 的 run() 方法中会调用传入的 Runnable 的 run() 方法执行任务,我们也可以覆写 Thread 的 run() 方法来让它直接执行我们的业务逻辑。所以,可以使用下面的两种方式使用线程,

    /**
    * 方式 1:覆写 run() 方法使用线程
    * 按照下面的方式来启动线程即可,
    * MyThread myThread = new MyThread(); 
    * myThread.start(); 
    */
    private class MyThread extends Thread {
        @Override
        public void run() {
            // 业务逻辑
        }
    }

    /**
    * 方式 2:使用 Runnable 使用线程
    */
    new Thread(new Runnable() {
        public void run() {
            // 业务逻辑
        }
    }).start();

对第二种方式,在创建多个线程的情况下,如果传入的 Runnable 实例是同一个实例的话,那么这几个线程是共享这个实例的数据的;如果不是同一个实例,则每个线程有一份自己的数据。当共享实例的时候,有时需要引入同步机制。

2.2 使用 Executor 创建线程池

2.2.1 线程池的基本使用

使用线程池的好处在于:因为线程的创建和销毁会占有一定的资源开销,尤其是当线程需要执行的逻辑耗时比较短,而创建和销毁的时间占用比较长的时候,对每个任务都创建和销毁线程就不太划算了。而线程池为我们提供了一种复用线程的机制,我们可以只创建执行数量的线程,然后将任务不断地提交到线程池中执行。

Executors 类有许多静态工程方法可以用来构建线程池:

  1. newCachedThreadPool():对每个任务,如果有空闲线程,立即让它执行任务,若无,则创建新线程;
  2. newFixedThreadPool():构建一个具有固定大小的线程池,若提交的任务数目大于空闲线程,得不到服务的任务放在队列中,执行完其他任务再执行这些任务;
  3. newSingleThreadPool():大小为1的线程池,提交的任务会按照提交的顺序依次地被执行(执行完毕一个,才去执行另一个);
  4. newWorkStealingPool():具有Work-Stealing (工作窃取) 的能力的线程池,Java8 中引入的,基于分治思想,Java8 中的并行流就是基于它来实现的。

使用线程池的时候,除了像线程那样启动任何,还可以获取到线程执行的结果。ExecutorService 的 submit() 方法提供了多个重载的版本,我们可以将 Runnable 或者 Callable 作为任务来执行。对于 Callable 类型的任务,该方法定义如下,

    <T> Future<T> submit(Callable<T> task)

也就是说,我们可以通过方法的返回结果得到一个 Future 实例。我们可以使用这个实例的方法来获取任务的执行结果,只是它的方法都是阻塞的。因此,我们又有了第三种使用线程的方式,

    // 方式 3:使用线程池并获取线程的执行结果
    // 定义固定大小的线程池
    ExecutorService executor = Executors.newFixedThreadPool(5);
    // 定义一个列表用来存储 submit 的返回实例
    List<Future<Integer>> results = new ArrayList<Future<Integer>>();
    for (int i=0; i<5; i++) {
        results.add(executor.submit(new CallableTask(i, i)));
    }
    // 输出每个任务的执行结果
    for (Future<Integer> result : results) {
        try {
            // 线程将会在 get() 方法上面阻塞
            System.out.println(result.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

此外,也可以使用 Future 的 isDone() 方法来判断任务是否完成,并决定是否要调用 get() 方法。

另外,还有一个类叫做 FutureTask。它同时实现了 Future 和 Runnable,所以,它设计的目的在于让我们可以同时从 FutureTask 实例中设置任务并获取结果。FutureTask 提供了两个构造方法,分别接受一个 Callable 和 Runnable 类型的实例。所以,我们可以把自己的任务放进 FutureTask 中包装了之后再传递给 ExectorService 或者 Thread 来执行。

2.2.2 线程池的参数选择

上面我们使用的是线程池的默认实现,也就是使用 Executors 的静态方法提供的线程池。这种方式是没有问题的,但是当我们使用阿里的插件的时候,会发现阿里并不推荐我们这样使用,而是使用手动创建线程池的方式。所以,在创建线程池的时候,我们有几个参数需要决定,

以 Android 为例,它并没有明确规定可以创建的线程的数量,但是每个进程的资源是有限的,线程本身会占有一定的资源,所以受内存大小的限制,会有数量的上限。通常,我们在使用线程或者线程池的时候,不会创建太多的线程。线程池的大小经验值应该这样设置:(其中 N 为 CPU 的核数)

  1. 如果是 CPU 密集型应用,则线程池大小设置为 N + 1;(大部分时间在计算)
  2. 如果是 IO 密集型应用,则线程池大小设置为 2N + 1;(大部分时间在读写,Android)

下面是 Android 中的 AysncTask 中创建线程池的代码(一些参数的意义已经添加到了注释种),

    // CPU 的数量
    private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
    // 核心线程的数量:只有提交任务的时候才会创建线程,当当前线程数量小于核心线程数量,新添加任务的时候,会创建新线程来执行任务
    private static final int CORE_POOL_SIZE = Math.max(2, Math.min(CPU_COUNT - 1, 4));
    // 线程池允许创建的最大线程数量:当任务队列满了,并且当前线程数量小于最大线程数量,则会创建新线程来执行任务
    private static final int MAXIMUM_POOL_SIZE = CPU_COUNT * 2 + 1;
    // 非核心线程的闲置的超市时间:超过这个时间,线程将被回收,如果任务多且执行时间短,应设置一个较大的值
    private static final int KEEP_ALIVE_SECONDS = 30;

    // 线程工厂:自定义创建线程的策略,比如定义一个名字
    private static final ThreadFactory sThreadFactory = new ThreadFactory() {
        private final AtomicInteger mCount = new AtomicInteger(1);

        public Thread newThread(Runnable r) {
            return new Thread(r, "AsyncTask #" + mCount.getAndIncrement());
        }
    };

    // 任务队列:如果当前线程的数量大于核心线程数量,就将任务添加到这个队列中
    private static final BlockingQueue<Runnable> sPoolWorkQueue =
            new LinkedBlockingQueue<Runnable>(128);

    public static final Executor THREAD_POOL_EXECUTOR;

    static {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                /*corePoolSize=*/ CORE_POOL_SIZE,
                /*maximumPoolSize=*/ MAXIMUM_POOL_SIZE, 
                /*keepAliveTime=*/ KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
                /*workQueue=*/ sPoolWorkQueue, 
                /*threadFactory=*/ sThreadFactory
                /*handler*/ defaultHandler); // 饱和策略:AysncTask 没有这个参数
        threadPoolExecutor.allowCoreThreadTimeOut(true);
        THREAD_POOL_EXECUTOR = threadPoolExecutor;
    }

饱和策略:任务队列和线程池都满了的时候执行的逻辑,Java 提供了 4 种实现;
其他:

  1. 当调用了线程池的 prestartAllcoreThread() 方法的时候,线程池会提前启动并创建所有核心线程来等待任务;
  2. 当调用了线程池的 allowCoreThreadTimeOut() 方法的时候,超时时间到了之后,闲置的核心线程也会被移除。

3、线程状态和生命周期

3.1 线程的状态

线程的状态转换

Java中的线程的生命周期大体可分为 5 种状态:

  1. 新建 (NEW):新创建了一个线程对象。

  2. 可运行 (RUNNABLE):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start() 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权 。

  3. 运行 (RUNNING):RUNNABLE 状态的线程获得了 CPU 时间片(timeslice) ,执行程序代码。

  4. 阻塞 (BLOCKED):阻塞状态是指线程因为某种原因放弃了 CPU 使用权,也即让出了 CPU timeslice,暂时停止运行。直到线程进入 RUNNABLE 状态,才有机会再次获得 CPU timeslice 转到 RUNNING 状态。阻塞的情况分三种:

    1. 等待阻塞:RUNNING 的线程执行 o.wait() 方法,JVM 会把该线程放入等待队列 (waitting queue) 中。

    2. 同步阻塞:RUNNING 的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池 (lock pool) 中。

    3. 其他阻塞:RUNNING 的线程执行 Thread.sleep(long)t.join() 方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入 RUNNABLE 状态。

  5. 死亡 (DEAD):线程 run()main() 方法执行结束,或者因异常退出了 run() 方法,则该线程结束生命周期。死亡的线程不可再次复生。

线程的 wait()notify() 调度原理:

线程的调度

3.2 线程的启动 start()、停止 stop()、挂起 suspend() 和唤醒 resume()

通过对象的 start(), stop(), suspend(), resume() 方法可以分别用来启动/停止/挂起/继续线程,但是后面三种方法都已经过时,调用可能发生不可预料的结果。如果线程被停止或者挂起的时候,它仍然占有共享的资源,那么有可能会导致线程死锁。

说明:调用 start() 方法,线程处于 runnable,线程可运行,但是无法确定线程是否正在运行,这取决于操作系统提供的运行时间。

run()方法执行结束之后,线程自动终止;如果 run() 无限循环,可以考虑加加标识,在一定情况下退出,不推荐使用 stop() 方法。另外,如果线程终止了,将无法再次启动。

一个线程会结束的原因可能是下面两者之一:1).run() 方法正常退出而线程自然地死亡;2).一个没有被捕获的异常终止了 run() 方法而意外地死亡。

3.3 线程休眠 Thread.sleep()

静态方法 Thread.sleep(long millis)Thread.sleep(long millis, int nanos) 强行将当前线程休眠(暂停执行)指定时间,睡眠结束,即返回可运行状态。

此外,也可以使用 TimeUnit.MILLISECONDS.sleep(1000); 方法来实现线程的休眠。

注意:

  1. 一个线程不能针对另一个线程调用 Thread.sleep(),即一个线程只能让自己睡眠;
  2. sleep() 方法会抛出一个 InterruptedException 异常。
  3. 如果当前线程获得了锁,sleep() 方法并不会使其失去锁

另外,对于 sleep()wait() 方法之间的区别,总结如下,

  1. 所属类不同:sleep() 方法是 Thread 的静态方法,而 wait() 是 Object 实例方法。
  2. 作用域不同:wait() 方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁。而 sleep() 方法没有这个限制可以在任何地方种使用。
  3. 锁占用不同:wait() 方法会释放占有的对象锁,使得该线程进入等待池中,等待下一次获取资源。而 sleep() 方法只是会让出 CPU 并不会释放掉对象锁;
  4. 锁释放不同:sleep() 方法在休眠时间达到后如果再次获得 CPU 时间片就会继续执行,而 wait() 方法必须等待 Object.notift()/Object.notifyAll() 通知后,才会离开等待池,并且再次获得 CPU 时间片才会继续执行。

3.4 线程优先级 setPriority()

优先级使用正整数试着,通常为 0~10,默认为 5. Thread类中也定义了 3 个静态最终常量:Thread.MIN_PRIORITY (对应整数值为 1)、Thread.NORM_PRIORITY (对应整数值为 5) 和 Thread.MAX_PRIORITY (对应整数值为 10).

线程是根据优先级调度执行的,尽管 CPU 处理现有的线程集的顺序是不确定的,但是调度器倾向于让优先权最高的线程先执行。这不意味着优先权低的程序得不到执行,只是执行的频率较低。

说明:

  1. 默认情况下,一个线程继承它父线程的优先级,可以使用 setPriority() 方法提高或降低一个线程的优先级;
  2. 高优先级线程没有进入非活动状态,低优先级线程永远不可能执行。每当调用一个新线程时,首先会在具有高优先级的线程中选择。尽管这样可能会使低优先级线程完全饿死;
  3. 在绝大多数的时间里,线程都应该以默认的优先级运行,试图操纵线程优先级通常是一种错误。
  4. 在不同 JVM 以及操作系统上,线程规划存在差异,有些操作系统甚至会忽略线程优先级的设定。

3.5 让步 Thread.yield()

即暂停当前正在执行的线程对象,并执行其他线程。并非永久暂停,只是让步一次执行时间片。需要注意的是,让出的CPU并不是代表当前线程不再运行了,如果在下一次竞争中,又获得了 CPU 时间片当前线程依然会继续运行。另外,让出的时间片只会分配给当前线程相同优先级的线程。

yield() 是 Thread 的一个静态方法,它给线程调度机制一个暗示:当前线程(在 run() 方法中调用 yield() 方法的线程)的工作已经差不多了,可以让别的线程使用 CPU 了。

但是,大体上,对任何重要的控制或在调整应用时,都不能依赖于 yield().

另外需要注意的是,sleep()yield() 方法,同样都是当前线程会交出处理器资源。它们不同的是,sleep() 交出来的时间片其他线程都可以去竞争,也就是说都有机会获得当前线程让出的时间片。而 yield() 方法只允许与当前线程具有相同优先级的线程能够获得释放出来的 CPU 时间片。

3.6 加入一个线程 join()

join() 是 Thread 的实例方法,它用来等待,直到指定线程结束。如果我们在线程 A 中调用了 B 的 join() 方法,就表示我们将 A 添加到了 B 的尾部,如果 B 不执行完 A 不继续执行。join() 重载版本,

void join()                         // 加入线程,等待该线程终止后运行
void join(long millis)              // 加入线程,等待该线程 millis 后运行,0 为无限等待
void join(long millis, int nanos)   // 加入线程,等待该线程 millis+nanos 后运行

线程的 join() 方法允许传入 long 型的时间,表示我们可以为线程设置等待的时间上限。当超过了指定的时间另一个线程仍然没有执行完毕任务,当前线程就继续执行自己的任务。否则,当前线程会一直阻塞。

注意,因为线程的 join() 方法的本意是等待另一份线程直到结束,所以,如果我们没有对指定的线程调用 start() 方法,那么 join() 是没有效果的(因为线程本来就没启动,所以也不用等待了)。

3.7 线程中断 interrupt()

线程中断需要注意两种情形,一个是未处于阻塞时期的中断,另一个是处于阻塞时期的中断

interrupt() 方法用来中断线程,而不是立即终止线程。对线程调用 interrupt() 方法时,线程的中断状态将被置位(设置为 true),这是每个线程都具有的 boolean 状态。如果想要知道一个线程是否被置位,可以使用 Thread.currentThread().isInterrupted() 来判断。

当线程由于调用了 sleep(), wait(), join() 等方法而进入阻塞状态;若此时调用线程的 interrupt() 将线程的中断标记设为 true。由于处于阻塞状态,中断标记会被清除,同时产生一个 InterruptedException 异常。

所以,当你希望让一个线程从阻塞状态中结束的时候,你可以按照下面这样去写,

@Override
public void run() {
    try {
        while (true) {
            // do something
        }
    } catch (InterruptedException ie) {  
        // 由于 InterruptedException 异常,退出 while 循环,线程终止!
    }
}

通常,我们把对 InterruptedException 的捕获务一般放在 while 循环体的外面,这样,在产生异常时就退出了 while 循环。

让线程结束,你还可以通过判断中断标志位来进行。此外,你还可以通过使用一个额外的布尔类型的变量来让线程退出。

    // 通过判断中断标志位来退出
    private static class MyRunnable implements Runnable {

        @Override
        public void run() {
            while (!Thread.currentThread().isInterrupted()) {
                // do something
            }
        }
    }

    // 通过一个布尔类型的变量来退出
    private static class MyRunnable2 implements Runnable {
        // 注意使用 volatile 修饰
        private volatile boolean canceled = false;

        @Override
        public void run() {
            while (!canceled) {
                // do something
            }
        }

        public void cancel() {
            canceled = true;
        }
    }

上面的第一种方式还没有考虑线程被阻塞的情况,所以,我们需要综合线程是否处于阻塞来给出一个更完美的版本,

@Override
public void run() {
    try {
        while (!isInterrupted()) {
            // do something
        }
    } catch (InterruptedException ie) {  
        // 线程因为阻塞时被中断而结束了循环
    }
}

最后,注意 interrupted() 和 isInterrupted() 的区别

interrupted() 是属于 Thread 的静态方法,isInterrupted() 是属于 Thread 的实例方法。interrupted()isInterrupted() 都能够用于检测对象的 “中断标记”。区别是,interrupted() 除了返回中断标记之外,它还会清除中断标记 (即将中断标记设为false);而 isInterrupted() 仅仅返回中断标记。

    public static boolean interrupted() {
        return currentThread().isInterrupted(/* ClearInterrupted= */ true);
    }

    public boolean isInterrupted() {
        return isInterrupted(/* ClearInterrupted= */ false);
    }

3.8 后台线程

Java 线程分为两类:用户线程和 Daemon 线程。

  1. 用户线程是通常意义的线程,Java 应用程序运行时,通过 main() 方法进入. 在主线程中可以创建和启动新线程,默认为用户线程. 只有所有用户线程结束后,应用程序才终止。
  2. 通过 setDaemon() 方法,可以设置线程为 Daemon 线程,在 Daemon 线程中创建的线程默认为 Daemon 线程. 通过方法 isDaemon() 可以判断一个线程是否为 Daemon 线程。
  3. Daemon 线程(守护线程)是一个服务线程,其 优先级最低,一般为其他线程提供服务. 通常 Daemon 线程体是一个无限循环,如果所有的非 Daemon 线程都结束了,则 Daemon 线程自动终止
  4. Daemon 线程应该永远不访问固有资源,如文件、数据等,因为它会在任何时候,甚至任一个操作中间发生中断。
  5. Deamon 线程通常是系统服务类线程,比如垃圾回收线程,JIT线程就可以理解守护线程。

3.9 线程组

最好把线程组看作一次不好的尝试,忽略就好。

3.10 线程控制 wait()、notify() 和 notifyAll()

  1. wait()、notify() 和 notifyAll() 方法是 Object 的本地 final 方法,无法被重写。

  2. wait() 使当前线程阻塞,直到接到通知或被中断为止。前提是必须先获得锁,一般配合 synchronized 关键字使用,在 synchronized 同步代码块里使用 wait()、notify() 和 notifyAll() 方法。如果调用 wait() 或者 notify() 方法时,线程并未获取到锁的话,则会抛出 IllegalMonitorStateException 异常。再次获取到锁,当前线程才能从 wait() 方法处成功返回。

  3. 由于 wait()、notify() 和 notifyAll() 在 synchronized 代码块执行,说明当前线程一定是获取了锁的。当线程执行 wait() 方法时候,会释放当前的锁,然后让出 CPU,进入等待状态。只有当 notify()/notifyAll() 被执行时候,才会唤醒一个或多个正处于等待状态的线程,然后继续往下执行,直到执行完 synchronized 代码块或是中途遇到 wait()再次释放锁
    也就是说,notify()/notifyAll() 的执行只是唤醒沉睡的线程,而不会立即释放锁,锁的释放要看代码块的具体执行情况。所以在编程中,尽量在使用了 notify()/notifyAll() 后立即退出临界区,以唤醒其他线程。

  4. wait() 需要被 try catch 包围,中断也可以使 wait 等待的线程唤醒。

  5. notify()wait() 的顺序不能错,如果 A 线程先执行 notify() 方法,B 线程再执行 wait() 方法,那么 B 线程是无法被唤醒的。

  6. notify()notifyAll() 的区别:
    notify() 方法只唤醒一个等待(对象的)线程并使该线程开始执行。所以如果有多个线程等待一个对象,这个方法只会唤醒其中一个线程,选择哪个线程取决于操作系统对多线程管理的实现。
    notifyAll() 会唤醒所有等待 (对象的) 线程,尽管哪一个线程将会第一个处理取决于操作系统的实现。如果当前情况下有多个线程需要被唤醒,推荐使用 notifyAll() 方法。比如在生产者-消费者里面的使用,每次都需要唤醒所有的消费者或是生产者,以判断程序是否可以继续往下执行。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值