Java多线程高并发系列:(二)线程和线程间通信

2024-10-22 19:50
23
0

一、线程和线程组

1.1、线程状态

Java中6个状态定义:java.lang.Thread.State

  • New:尚未启动的线程的线程状态。
  • Runnable:可运行线程的线程状态,等待CPU调度。
  • Blocked:线程阻塞等待监视器锁定的线程状态。 如:处于synchronized同步代码块或方法中被阻塞。
  • Waiting:线程等待的线程状态。不带timeout参数的方式调用Object.wait、Thread.join、LockSupport.park
  • Timed Waiting:具有指定等待时间的等待线程的线程状态。下列带超时的方式:Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil
  • Terminated:终止线程的线程状态。线程正常完成执行或者出现异常。

1.2、启动线程

Java里启动线程的方式归根结底只有一种:new Thread().start()

Runnable、Callablle->FutureTask只是Thread要执行的逻辑。

1.2.1、Runnable和Callable

Runnable就没什么好说的了,基本上大家都会用,这里主要说说Callable.

Callable接口和Runnable接口相似,区别就是Callable需要实现call方法,而Runnable需要实现run方法;

Thread执行只支持Runnable,要用到Callable该怎么办?这里就要提到Java中的Executors工具类。

Callable和Runnable都可以通过Executors工具类执行,Executors工具类的详解后面再说,这里只说说对Callable的用法。

Callable可以通过标准的线程池执行类ThreadPoolExecutor调用,具体代码如下:

public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 创建单一线程的执行器
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        // 执行Callable
        Future<String> future = executorService.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                System.out.println("执行线程逻辑>>>>");
                Thread.sleep(2000);
                return "执行完成";
            }
        });
        // 获得Callable返回结果,Callable没执行完会阻塞在此
        System.out.printf("执行结果:" + future.get());
        executorService.shutdown(); // 最后关闭执行器
    }

1.3、如何停止一个线程

  1. run方法代码执行完成
  2. 线程运行时抛出一个未捕获的异常,跳出线程
  3. 死循环,通过标志位跳出线程
  4. interrupt() 向需要中断的线程发送停止指令;isInterrupted() 线程检查自己的中断标志位;Thread.interrupted() 将中断标志位复位为false

不安全方式

  1. Stop() 立刻停止线程,但不会释放线程运行所应用的资源
  2. Suspend() 立刻挂起线程,但不会释放线程运行锁应用的资源,容易造成死锁
  3. Destroy()JDK未实现

interrupt方法

  • interrupt方法并不会中断线程,只是打上中断标记(isInterrupted)
  • 如果目标线程在调用wait()wait(long)方法、join()join(long, int)join(long, int)sleep(long, int)sleep(long, int)等方法后,处于WAITINGTimed Waiting状态时,该线程被调用interrupt方法后,线程的WAITING Timed Waiting状态将被清除,变成Runnable,并抛出InterruptedException异常,同时中断标记会变成false
  • park()\parkNanos方法执行后,线程也处于 WAITINGTimed Waiting,调用interrupt方法也会被唤醒,但是不会抛异常,且park就相当于已经拿到了许可(类似于执行了unpark,后续再调用此park不会在WAITING中断标记不会被修改。(具体park的使用查看线程间通信章节)
  • 如果目标线程是被I/O 或者NIO中的Channel所阻塞,同样,I/O操作会被中断或者返回特殊异常值。达到终止线程的目的。
  • 如果以上条件都不满足,则会设置此线程的中断状态。

正确的使用interrupt

  1. 通过中断标记和while循环里判断中断标记来达到中断线程的目的(死循环,通过标志位跳出线程)。如果while代码中有sleepwaitjoinIO操作,记得InterruptedException中重新设置中断标志(因为抛出异常后标记会自动变为false),以达到让while循环关闭的目的
  2. 中断标记会让一些waitsleepparkIO操作等不再阻塞直接中断其操作,让线程继续往下执行。
public static void main(String[] args) throws InterruptedException {
    Thread testThread = new Thread(){
        @Override
        public void run() {
            //中断标志为false就进入执行
            while (!Thread.currentThread().isInterrupted()){
               try {
                    System.out.println("runing...");
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    //如果有代码中有sleep、wait、join
                   // 记得在InterruptedException中重新设置中断标志
                   //以达到让while循环关闭的目的
                    Thread.currentThread().interrupt();
                }
            }
        }
    };
    testThread.start();
    Thread.sleep(1000);
    testThread.interrupt();
}

1.4、守护线程

守护线程:是指在程序运行的时候在后台提供一种通用服务的线程,进程结束时,会杀死所有守护线程。

用户线程:非守护线程就是用户线程

进程结束:没有非守护线程还在运行时,进程结束

注意:

  • thread.setDaemon(true)必须在thread.start()之前设置,否则会抛出一个IllegalThreadStateException异常。你不能把正在运行的常规线程设置为守护线程。
  • Daemon线程中产生的新线程也是Daemon的。
  • 守护线程建议不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。

1.5、线程组

 

ThreadGroup的提出是为了方便线程的管理,通过它可以批量设定一组线程的属性,比如setDaemon,设置未处理异常的处理方法,设置统一的安全策略等等;也可以通过线程组方便的获得线程的一些信息,但因为ThreadGroup这个类本身不是线程安全的,所以在多线程环境下使用ThreadGroup实例,要注意线程安全。尽量不要使用,会带来线程安全问题。

主线程里添加了线程或者线程组一般和主线程在同一个组,即main group下。通过debug模式,也可以查看到线程组

1.6、ThreadLocal实现线程封闭

多线程访问共享可变数据时,涉及到线程间数据同步的问题。

并不是所有时候都要用到共享数据,若数据都被封闭在各自的线程红,就不需要同步,这种通过将数据封闭在线程中而避免使用同步的技术称为线程封闭。

ThradLocal是一个线程级别的变量,每个线程都有一个ThreadLocal就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了,在并发模式下是绝对安全的变量。

用法:

ThreadLocal<T> var = new ThreadLocal<T>();

会自动在每个线程上创建一个T的副本,副本之间彼此独立,互不影响。

可以用ThreadLocal存储一些参数,以便在线程中多个方法中使用,用来代替方法传参的做法。

扩展:栈封闭

局部变量的固有属性之一就是封闭在线程中,因为他们都位于执行线程的栈中,其他线程无法访问这个栈。

二、线程间通信

JDK中对于需要多线程协作完成某一任务的场景,提供了对应API支持。例如:wait/notify、park/unpark和被弃用的suspend和resume。

  • wait会释放锁,parksuspend不会释放锁。
  • unpark可以在park之前执行,resumenotify不能,会造成死锁。

2.1、wait/notify机制

wait方法导致当前线程等待,加入该对象的等待集合中,并且放弃当前持有的对象锁

notify/notifyAll方法唤醒一个或所有正在等待这个对象锁的线程。

notify() 唤醒一个线程(谨慎使用),具体唤醒哪个线程,由CPU决定。

注意:

  • 虽然wait会自动释放对象解锁,但对顺序有要求,如果在notify被调用之后,才开始wait方法的调用,线程会永远处于WAITING状态。
  • 这些方法只能由同一对象锁的持有者线程调用,也就是写在同步块里,否则会抛出IllegalMonitorStateException异常。(没有suspend和resume的问题)
  • wait方法调用后,会破坏原子性。因为其将锁释放了。

扩展:sleepwait的区别

  • sleep()是线程Thread的方法,而wait()是Object对象的方法。
  • sleep()不会释放对象锁、wait()会释放对象锁。
  • sleep()可以在任何地方调用,wait()方法之可以在同步方法或同步块中使用。

2.2、park/unpark机制

线程调用park则等待“许可”,unpark方法为指定线程提供“许可(permit)”

调用unpark之后再调用park线程会直接运行。

提前调用unpark不叠加,连续多次调用unpark后,第一次调用park后会拿到“许可”直接运行,后续调用会进入等待。

代码示例:

public static void main(String[] args) throws InterruptedException {
    // 开启一个线程
    Thread consumerThread = new Thread(() -> {
        System.out.println("等待许可...");
        LockSupport.park();
        System.out.println("结束");
    });
    consumerThread.start();
    Thread.sleep(2000L);
    LockSupport.unpark(consumerThread);
    System.out.println("拿到许可");
}

执行结果:

等待许可...
拿到许可
结束

在同步代码块中使用,容易出现死锁,因为park不会释放锁,代码示例:

public void test_DeadLock() throws InterruptedException {
    Thread consumerThread = new Thread(() -> {
        System.out.println("线程内的锁:" + this);
        System.out.println("等待许可...");
        synchronized (this) {
            LockSupport.park();
        }
        System.out.println("结束");
    });
    consumerThread.start();
    Thread.sleep(2000L);
    System.out.println("主线程的锁:" + this);
    synchronized (this) {
        LockSupport.unpark(consumerThread);
    }
    System.out.println("拿到许可");
}

执行结果:死锁

扩展:

Java匿名内部类如果用new生成的内部再从中获得this和通过Lambda表达式中获得this,两种方式取得的对象是不一样的。

匿名类中的this是匿名对象本身,Lambda表达式中的this是调用Lambda表达式的对象,所以上面代码中两个this打印的都是外部类的地址。

如果把上面代码改成匿名内部类new的方式生成,代码示例如下:

public void test2_DeadLock() throws InterruptedException {
    Thread consumerThread = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("线程内的锁:" + this);
            System.out.println("等待许可...");
            synchronized (this) {
                LockSupport.park();
            }

            System.out.println("结束");
        }
    });
    consumerThread.start();
    Thread.sleep(2000L);
    System.out.println("主线程的锁:" + this);
    synchronized (this) {
        LockSupport.unpark(consumerThread);
    }
    System.out.println("拿到许可");
}

执行结果:程序正常执行完成,因为两边获得的锁不一样,相当于没有锁住。

2.3、Thread.yield()机制

Thread.yield()是 Java 中的一个方法,用于提示线程调度器当前线程愿意放弃当前的 CPU 使用权,允许相同优先级的其他线程获得执行的机会。

作用:

提示调度器:当前线程通知线程调度器,它愿意让出自己的 CPU 时间片,即使它仍有剩余的执行时间。

提高线程调度的公平性:可以提高线程调度的公平性,避免某个线程长时间占用 CPU,导致其他线程饥饿。

协作式线程调度:线程之间需要协作的某些情况,可以用来实现线程之间的协作调度。

注意事项:

不保证一定会让出 CPU: yield方法并不保证线程一定会放弃 CPU,线程调度器可能会忽略这个提示,当前线程可能仍然继续执行。

不改变线程优先级:不会影响线程的优先级,它只是向线程调度器发出一个提示。

使用场景有限:在现代操作系统中,线程调度通常是由操作系统内核管理的,因此yield()的使用场景相对有限。在大多数情况下,线程调度器已经能够很好地管理线程的执行。

可能影响性能:频繁地调用此方法可能会导致性能下降,因为它会增加线程上下文切换的开销。

2.4、被弃用的suspend和resume

调用方式:Thread.currentThread().suspend();Thread.currentThread().resume();

作用:调用suspend挂起目标线程,通过resume可以恢复线程执行。

被弃用原因:

  1. 容易写出死锁代码,如在synchronized里拿到了锁然后挂起了线程,不会自动释放持有的对象锁,另一个线程里resume就拿不到锁唤醒不了。
  2. resume在suspend之前,也会一直处于死锁。

2.5、伪唤醒

注意:代码中用 if 语句来判断,是否进入等待状态,这样的做法是错误的!

官方建议应该在循环中检查等待条件,原因是处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。

伪唤醒是指线程并非因为notify、notifyall、unpark等api调用而意外唤醒,是更底层原因导致的。

 

全部评论