Java 并发编程-线程基础

1. 线程相关概念

1.1 线程和进程的关系

  1. 构成关系上看,线程是进程中的一个实体,一个进程中至少有一个线程,线程本身不能独立存在。

  2. 程序角度来看,进程是代码在数据集合上的一次运行活动,线程是进程的一个执行路径。

  3. 从操作系统资源分配上看,进程是系统进行资源分配和调度的基本单位,线程是CPU分配的基本单位。

  4. 通信方式上看, 进程通信有管道、有名管道、信号量、消息队列、信号、共享内存、套接字方式,线程有锁机制、信号量机制、信号机制。线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。

  5. 资源访问的角度上看,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。如下图所示

1.2 线程上下文切换

  • CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这 个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

  • JVM 使用程序计数器来记住下一条指令的执行地址,是线程私有的

  • 线程上下文频繁切换会影响性能,所以不是线程越多越好。这也是为什么在cpu核心数量只有一个的系统上,并发的效果甚至不如串行。


1.3 线程优先级

  • 现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需 要多或者少分配一些处理器资源的线程属性。

  • Java 中 Thread 对象有一个整形变量 priority 来控制优先级,可以通过 setPriority() 来设置这个值, 但是 Java 中线程优先级的设置效果不明显,操作系统可能会忽略这个线程优先级的设置.


1.4 线程状态

1.4.1 操作系统线程状态(五种)

状态名称 说明
初始状态 仅在语言层面创建了线程对象,还未与操作系统线程关联
可运行状态 指该线程已经被创建(与操作系统线程关联),可以由CPU调度执行
运行状态 指获取了CPU时间片运行中的状态
阻塞状态 如果调用了阻塞API,这时线程实际不会用到CPU,会导致上下文切换,进入阻塞状态。等操作完毕,由操作系统唤醒阻塞的线程,转换至可运行状态
终止状态 表示线程已经执行完毕,生命周期已经结束,不会转化为其他状态

(图片源自网络)

1.4.2 Java线程状态(六种)

根据 Thread.State 枚举,分为六种状态

状态名称 说明
NEW 初始状态,线程被构建,但是还没有调用start() 方法
RUNNABLE 运行状态,Java 线程将操作系统中的就绪、运行、阻塞三种状态笼统地称作“运行中”
BLOCKED 阻塞状态,表示线程阻塞于锁
WAITING 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知和中断)
TIME_WAITING 超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的
TERMINATED 终止状态,表示当前线程已经执行完毕

Java线程状态变迁

(图片来自《Java并发编程的艺术》)


1.5 线程死锁

  • 四个必要条件:互斥条件,请求并持有条件,不可剥夺条件,环路等待条件。
  • 避免死锁只需要破环至少一个必要条件,目前只有请求并持有和环路等待条件可以被破环。
  • 资源申请的有序性原则可以避免死锁。

2.创建线程的三种方法

2.1 方法一:继承 Thread ,重写 run 方法

public class ThreadTest1 {
    
    // 继承 Thread 并重写 run 方法
    public static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("t1 run");
        }
    }

    public static void main(String[] args) {
        // 创建线程
        MyThread thread = new MyThread();
        // 启动线程
        thread.start();
    }
}

由于Java 不支持多继承,继承了 Thread 类就不能再继承其他类。并且任务和代码没有分离,当多个线程执行一样的任务时需要多份代码。
而实现 Runnable 接口的方法解决了上面的问题 。

2.2 方法二:实现 Runnable接口

public void method2() {
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            log.debug("running");
        }
    };
    
    // jdk8 之后可以用lambda 表达式简化上面的代码
    Runnable runnable1 = () -> {
        log.debug("running");  
    };
    
    Thread t = new Thread(runnable,"t");
    t.start();
}

RunableTask 可以继承其他类。并且两个线程共用了一个 task 代码逻辑。

但是,方法一和方法二都没有返回值,下面看方法三使用 FutureTask。

2.3 方法三:Callable + FutrueTask

public class ThreadTest3 {

    // 创建任务类,类似 Runnable
    public static class CallerTask implements Callable<String> {
        @Override
        public String call() throws Exception {
            return "call";
        }
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建异步任务
        FutureTask<String> futureTask = new FutureTask<>(new CallerTask());
        // 启动线程
        new Thread(futureTask).start();
        try {
            // 等待任务执行完毕,返回结果
            String result = futureTask.get();
            System.out.println(result);
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

2.4 方法一和方法二的比较

方法一 继承 Thread

  • 优点

    1. 在 run() 方法内获取当前线程直接使用 this 就可以,无须使用 Thread.currentThread() 方法。
    2. 使用继承的好处是方便传参,你可以在子类里面添加成员变量,而如果使用 Runnable 方式,则只能使用主线程里面被声明为 final 的变量
  • 缺点

    1. Java 不支持多继承,如果继承了 Thread 类那么就不能再继承其他类。
    2. 任务和代码没有分离,当多个线程执行一样的任务时需要多份代码,而 Runnable 则没有这个限制 。
    3. 任务没有返回值

方法二 实现 Runnable 接口

  • 优点
    1. 用 Runnable 更容易与线程池等高级 API 配合
    2. 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活,Java 中使用组合一般要优于使用继承
      “有些时候,通过‘合成’技术用现有的类来构造新类。而继承是最少见的一种做法。”
      “应提醒自己防止继承的滥用。”(《Java编程思想》)
  • 缺点
    1. 没有返回值

3. 开启线程:start

3.1 start 和 run 的区别

  1. start() 是开启一个线程,run() 是这个线程要执行的方法
  2. 调用 start() 后这时此线程是处于就绪状态,并没有运行,真正运行取决于操作系统
  3. 能不能直接调用 run() 方法呢? 可以,但是执行这个 run() 方法的线程就是调用该方法的线程,而不是新创建一个线程去执行。
public  void testStartRun() {
    Thread t = new Thread(){
        @Override
        public void run() {
            try {
                sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };
    /*
    t.run();  //5秒后打印hello
    */  
    t.start(); //立刻打印hello
    System.out.println("hello");
}

4. 线程等待与通知

4.1 等待 wait

  • 当一个线程调用一个共享变量的 wait() 方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:
    (1)其他线程调用了该对象的 notify 或者 notifyAll 方法
    (2)其他线程调用了该线程的 interrupt 方法,该线程抛出 InterruptedException 异常返回
  • 调用 wait 方法的线程必须要先获得该对象的监视器锁,否则抛出 IllegalMonitorStateException 异常。
  • 当前线程调用共享变量的 wait() 方法后只会释放当前共享变量上的锁,如果当前线程还持有其他共享变量的锁,则这些锁是不会被释放的

4.2 通知 notify / notifyAll

  • notify() 方法会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的进程;
  • notifyAll() 方法则会唤醒所有在该共享变量上调用 wait 系列方法后被挂起的进程;
  • 被唤醒的线程不能马上从 wait 方法返回并继续执行,它必须在获取了共享对象的监视器锁之后才可以返回。
  • 只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的notify() 方法,否则会抛出IllegalMonitorStateException 异常

4.3 虚假唤醒

  • 一个线程可以从挂起状态变为可运行状态正常情况下为四种,其他情况被唤醒就是虚假唤醒
    (1)该线程被其他线程调用notify() 进行通知,
    (2)该线程被其他线程调用notifyAll() 方法进行通知,
    (3)该线程被其他线程中断
    (4)等待超时
  • 虚假唤醒在实践中发生的概率很小,一般在循环中调用 wait() 方法来进行防范

5.等待线程终止 join

join 作用:

  • 插入线程中,被插入的线程进入阻塞状态需要等待插入线程运行结束。

  • 可以指定一个 long 值,规定等待的时间

下面是一个使用 join 来实现同步的例子

同步与异步

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
//方法外声明一个变量
int r = 0;
public void testJoin() throws InterruptedException {
    Thread t = new Thread(){
        @Override
        public void run() {
            try {
                sleep(2000);
                r = 10;	//子线程修改 r 变量
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };
    //开启子线程
    t.start();
    //如果去掉下面这行,打印结果为 r = 0
    t.join(); //主线程需要等待t线程结束才能继续运行
    log.debug("r={}",r);	// r = 10
}

6. 线程睡眠 sleep

6.1 sleep

  1. sleep 会让当前线程从 Running 进入 Timed Waiting 状态 (阻塞)
  2. 其他线程可以使用 interrupt() 打断正在睡眠的线程,sleep() 会抛出 InterruptedException
  3. 睡眠结束后的线程未必会立刻得到执行

6.2 sleep 和 wait 的区别

  1. sleep() 是 Thread 类的静态方法;wait() 是 Object 类的方法,任何对象实例都能调用
  2. sleep() 不会释放锁,也不需要占用锁;wait() 会释放锁,但前提是当前线程占有锁,即 wait()只能用于同步代码块中。
  3. sleep() 会暂停线程休眠指定的时间后自动恢复,调用 wait() 的线程会进入等待队列,直到被 notify()/notifyAll() 唤醒去争夺锁。
  4. 都可以被 interrupt 方法打断

7. 线程CPU让权 yield

7.1 yield

  1. 调用 yield 会让当前线程从 Runing 进入 Runnable 状态,然后调度执行其他同优先级的线程。如果这时没有同优先级的线程,那么不能保证让当前线程暂停。
  2. 具体实现依赖于操作系统的任务调度器。

7.2 sleep 和 yield 的区别

  1. sleep 后线程处于 Timed Waiting 状态,cpu 不会分配时间片给这个线程
  2. yield 后线程处于Runnable 状态,cpu可能分配时间片给它

8. 线程中断

8.1 void interrupt()

  • 中断线程,如果该线程因为调用wait(),join(),sleep()而被阻塞挂起,抛出InterruptedException异常。否则,会将中断状态设置为 true。
    8.2 boolean isInterrupted()
  • 检测当前线程是否被中断,如果是返回 true, 否则返回false。
  • 不会清除中断标志。
    8.3 boolean interrupted()
  • 检测当前线程是否被中断,如果是返回 true, 否则返回false。
  • 与isInterrupted不同,会清除中断标记。
  • 另外与isInterrupted不同在于,interrupted是static方法,内部是获取当前调用线程的中断标志而不是调用interrupted方法的实例对象的中断标志
public void testInterrupt() throws InterruptedException {
    Thread t = new Thread() {
        @Override
        public void run() {
            while (true) {
                //用 isInterrupted() 方法测试这个线程是否被中断
                boolean interrupted = isInterrupted();
                if (interrupted) {
                    log.debug("被打断,退出循环");
                    break;
                }
            }
        }
    };
    t.start();
    //执行中断
    t.interrupt();
    log.debug("打断标记:{}",t.isInterrupted());
}
/**输出结果
13:36:44.373 [Thread-0] DEBUG c.MethodTest - 被打断,退出循环
13:36:44.373 [main] DEBUG c.MethodTest - 打断标记:true
**/

9. 用户线程和守护线程

用户线程:平时我们创建的线程一般都属于用户线程

守护线程:即 Daemon thread,是个服务线程,准确地来说就是服务其他的线程,比如垃圾回收器就是一种守护线程,只要其他非守护线程运行结束了,则守护线程会强制结束


10. 其他补充

10.1 JDK 线程指令
JDK 提供了专门指令

  • jps 命令查看所有Java进程
  • jstack 查看某个Java 进程的所有线程状态
  • jconsole 图形界面来查看某个 Java 进程中线程的运行情况
posted @ 2022-03-08 14:35  油虾条  阅读(23)  评论(0编辑  收藏  举报