Java 基础 - 多线程基础

余生颓废 提交于 2020-02-20 09:47:50

并发

并发在单核和多核 CPU 上都存在, 对于单核 CPU,通过轮训时间片的方式实现并发.


线程

线程对象

利用Thread对象, 有两种方式来创建并发程序:

  1. 直接创建并管理线程. 当程序要启动一个异步任务的时候, 直接创建一个线程.
  2. 将线程管理抽象出来, 把并发部分的任务交给 executor.

线程的创建

有两种方式创建线程:

  1. 提供一个实现Runnable接口的对象.
  2. 子类化Thread.

两种方法的优缺点?

Runnable 总体来说更好一点

  1. 使用Runnable 接口的方式更加灵活, 因为可以继续子类化某个类
  2. Runnable 接口的方式可以适配 concurrent包中的高级线程管理 API

线程的基本状态

线程有如下状态:

  1. NEW: 线程已经创建, 但还没有调用 start() 开始执行.
  2. RUNNABLE: 线程已经在 JVM 中开始运行, 但有可能在等待系统资源运行.
  3. BLOCKED: 线程在等待一个 monitor lock 以进入一个 synchronized 块. 也有可能是这个线程刚执行完 wait(), 其他线程又在获取 wait() 对象的 monitor lock.
  4. WAITING: 线程进入等待状态, 以下方法会使得线程进入 wait() 状态:

    • Object.wait()
    • join()
    • LockSupport.park
  5. TIMED_WAITING: 线程进入有时间限制的等待, 一下方法会使得线程进入此状态:

    • Thread.Sleep
    • Object.wait(long)
    • Thread.join(long)
    • LockSupport.parkNanos
    • LockSupport.parkUntil
  6. TERMINATED: 线程执行结束.

注意: 

当线程 A 调用某个对象的 Synchronized 方法的时候, 线程就获得了这个对象的 intrinsic lock, 线程的状态是 RUNNABLE. 其他线程假如要获取这把锁, 就会进入 BLOCKED 状态.

当线程 A 调用wait方法, 那线程A 会释放这个对象的锁(但是扔回持有其他对象的锁, 假如有的话), 然后线程转入 WAITING 状态. (若之前很多对象 pending 在这个 lock 上, 那么, 进入 wait 后会唤醒其他某个线程么?)

当其他某个线程 B 在同一个对象(也就是线程 A wait 释放的同一把锁)调用notify 或者 notifyAll时, 线程A 状态由 WAITING 转化为 BLOCKED. 此时, 线程A 并不会自动获取到锁或者状态变成 RUNNABLE, 实际上, 线程 A 也要和其他被阻塞的线程一样竞争这把锁.

WAITING 和 BLOCKED 状态都会阻止线程运行, 但是区别却很大.

WAITING 状态必须被其他线程调用notify从而显式的转化为 BLOCKED 状态. WAITING 状态从来不会直接转化为 RUNNABLE.

当一个 RUNNABLE 线程释放了锁(正常结束或者waiting), 某个被阻塞的线程会自动被唤醒.

notify 和 notifyAll 区别?

notify 唤醒被同一把锁 wait的第一个线程 notifyAll 唤醒同一把锁 wait 的所有线程, 但是优先级最高的先执行

Thread.sleep

Thread.sleep 导致当前线程暂时挂起一段时间, 其他线程可以有机会获取到 CPU 时间.

两个 API:

Thread.sleep(long ms)
Thread.sleep(long ms, long ns)

时间并不精确, 由于底层 OS 实现的限制.

Sleep 可以被打断, 当线程 A 在休眠, 而另外一个线程 B 调用 A.interrupt()时, 线程 A 就会抛出 InterruptedException

中断

中断

interrupt 会停止当前线程的正在进行的任务并且取做别的事情. 至于一个 thread 应该如何响应一个中断则是由程序员决定的. 

响应中断

根据当前任务的长短, 做不同的处理:

  1. 当一个线程正在频繁的调用一个会抛出InterruptedException 的方法时, 它可以通过 try...catch 捕获, 并在catch 中做处理. 有很多方法会抛出InterruptedException, 比如 Thread.sleepsleep的中断行为被设计成为:终止当前操作并抛出异常.

    for (int i = 0; i < ary.length; i++) {
        try {
            Thread.sleep(4000);
        } caatch (InterruptedException e) {
            return;
        }
        System.out.println(importantInfo[i]);
    
    }
  2. 当一个线程在执行一个长时间任务, 并且这个任务并没有抛出 InterruptedException 的时候. 那么就需要不停地去检测当前线程有没有被中断:

    for (int i = 0; i < inputs.length; i++) {
        heavyCrunch(inputs[i]);
        if (Thread.interrupted()) {
            // or return;
            throw new InterruptedException();
        }
    }

中断的标志位

中断机制是由内部标识中断状态的一个标志控制的:

  • 当调用 Thread.interrupt时, 这个标志会被设置. 
  • 当调用 Thread.interrupted时, 标志会被清理.
  • 当调用 Thread.isInterrupted 查询中断状态时, 标志不变.
  • 任何方法假如因为 InterruptedException 而退出, 那么中断标志会被清理(但是有可能立即被设置).

Join

join 方法让一个线程可以等待另外一个线程执行结束后再往下执行. 和 sleep一样, join 响应中断的方式也是退出并且抛出InterruptedException.


线程同步

线程间通讯主要靠开放字段的访问或者字段引用的对象. 会带来两个问题:

  1. 线程干扰(thread interference)
  2. 内存一致性错误(memory consistency)

防止这两种错误的机制就是 线程同步. 然而, 线程同步会带来线程间竞争(thread contention). 饥饿和活锁都是 线程竞争 的表现.

线程干扰 - Thread Interference

指的是, 一个语句可能被虚拟机拆分成很多步执行, 然而当两个线程交叉执行时, 线程 A 的执行导致线程 B 的运行结果是不准确的. 比如, 线程 A 执行 c++, 线程 B 执行 c--, 开始两个线程读到 c 的值是0, 假如线程 B 在 A 之后执行完, 那么结果是 -1 而不是 0.

内存一致性错误 - Memory Consistency Error

Memory Consistency Error 指的是线程对同一份数据却有不一致的值. 防止这种错误的关键是保证 happens-before 关系. 比如:

    int count = 0;

线程 A 执行:

    count++;

线程 B 打印 count的值:

    System.out.println(count);

那么线程 B 打印出的结果可能是 0, 因为线程 A 的自增操作并没有和 B 的打印语句建立一个 happens-before 关系_.

happens-before 关系 保证的是, 某个语句所导致的内存写入动作会对另外的语句可见. 

如何建立 happens-before 关系?

  1. 同一个线程, 前面的指令总比后面的指令先执行
  2. 释放 monitor lock(离开 synchronized 代码块或者方法), 总是发生在获取同一个 monitor lock(进入 synchronized 代码块或者方法)之前. 由于 happens-before 关系的传播性, 释放锁的方法或者代码块, 总是比获取锁或者代码块之前执行. 
  3. 写入volatetile 变量总是在读取前执行. 
  4. 调用 Thread.start 建立两种 happens-before 关系:
    • Thread.start前面的语句会在 Thread.start 之前执行
    • Thread.start前面的语句会在新线程语句之前执行
  5. 线程 A 结束并导致线程 B Thread.join返回, 线程 A 中的所有语句会在线程 B Thread.join后面的语句之前执行

线程同步方法

Java 语言级别提供了两种方法:

  1. synchronized method
  2. synchronzied statements

注意:

  • 构造函数不能用 synchronized 修饰, 否则会发生编译错误.
  • 对象的构造一定是在同一个线程中完成的.
  • 不要提早的泄露字段的引用. 比如在构造函数添加下列的语句:

    instances.add(this) //就会导致构造函数并未完成, 却将它通过 instances 暴露出去了.

Intrinsic Lock / Monitor Lock

同步的机制是建立在 intrinsic lock 又称 monitor lock 之上的. Intrinsic Lock 作用是

  • 强制独占对象的状态(只要一个线程有一个 intrinsic lock, 其他线程是获取不到这个锁的)
  • 建立一种 happens-before 关系
  • 保证了状态更改的可见性.

synchronized method 中的锁

当线程调用一个 synchronized method 的时候, 线程会自动获得对象的 intrinsic lock. 并在下列情况释放:

  • 方法正常返回
  • 有未捕获异常发生

当线程调用一个 static synchronized method 的时候, 线程会获取与对象关联的 Class 对象的锁. 所以静态同步方法的锁和实例锁是不同的.

synchronzied statements

写法:

 public void addName(String name) {
        synchronized(this) {
                lastName = name;
                nameCount++;
        }
        nameList.add(name);
 }

注意: 在 synchronized method 或者 synchronzied statements 中要避免其他对象的同步代码(方法或代码块).

同步代码重入

  • 一个线程不可以获得其他线程拥有的锁
  • 一个线程可以获取自身已经拥有的锁

原子访问

意思是: 不可打断的操作

Java 中的原子操作:

  • 读取或改变引用类型的引用
  • 读取或改变基本类型(long 和 double 除外)
  • 读取或改变所有volatile类型的变量(包括引用, longdouble)

原子操作不会被拆分, 所以不用担心线程干扰(Thread Interference)的问题, 但是却仍然要注意内存一致性(Memory Consistency)的问题. 

使用volatile 可以避免内存一致性错误, 因为写入volatile 变量建立了一种 happen-before 的关系: 写入总比后续读先

synchronized method 和 synchronized statements 会保证原子操作. 


Liveness

Deadlock

死锁描述了这样一种情况: 两个或者多个线程进入永远的阻塞, 互相等待.

避免方法: 上锁的顺序相同.

如何排查? 通过 JStack 可以查看:

jstack <pid>

Java stack information for the threads listed above:
===================================================
"Thread-1":
    at basic.DeadlockBower$Friend.bowBack(DeadlockBower.java:32)
    - waiting to lock <0x0000000795706590> (a basic.DeadlockBower$Friend)
    at basic.DeadlockBower$Friend.bow(DeadlockBower.java:28)
    - locked <0x00000007957065d8> (a basic.DeadlockBower$Friend)
    at basic.DeadlockBower$2.run(DeadlockBower.java:49)
    at java.lang.Thread.run(Thread.java:745)
"Thread-0":
    at basic.DeadlockBower$Friend.bowBack(DeadlockBower.java:32)
    - waiting to lock <0x00000007957065d8> (a basic.DeadlockBower$Friend)
    at basic.DeadlockBower$Friend.bow(DeadlockBower.java:28)
    - locked <0x0000000795706590> (a basic.DeadlockBower$Friend)
    at basic.DeadlockBower$1.run(DeadlockBower.java:43)
    at java.lang.Thread.run(Thread.java:745)

Found 1 deadlock. 

Starvation

饥饿描述了这样的情况: 线程无法长时间访问不到共享资源, 从而无法取得进展

Livelock

描述的是: 两个线程互相根据对方的行为做出响应, 导致各自没有实质性的进展

Guarded Blocks

线程之间经常协调他们的行为, 其中最常用的协调方法是 guarded block:

public void guardedJoy() {
    // Simple loop guard. Wastes processor time. Don't do this!
    while (!joy) { }

    System.out.println("Joy has been achieved"); 
}

上面的代码通过不停 检测 joy 的状态来决定是否往下执行. 这样非常的耗费 CPU 时间.

更好的应该用 Object.wait() 来挂起当前线程.

public synchronized void guardedJoy() {

    // This guard only loops once for each special event, which may not be 
    // the event we're waiting for.

   while (!joy) {

        try {
            wait();
        } catch (InterruptedException e) {}
   }
   System.out.println("Joy and efficiency have been achieved!");

}

值得注意的是, 确保 wait() 在一个循环中, 因为你不能保证:

  1. 是 InterruptedExcpetion 还是正常唤醒导致 wait() 结束
  2. 唤醒的线程是否将 joy 的值改变(尤其是在 notifyAll中)

消费者和生产者模式

解决了, 解耦了生产者和消费者, 并且更合理的运用了 CPU 时间.

不可变对象

不可变对象是那些构造后状态无法被改变的对象. 由于它的不可变性, 他不会有 Thread interference 和 Memory inconsistent 等问题.

不可变对象的特征:

  1. 不要设置 setter 方法.
  2. 所有的成员都设置成 final + private
  3. 不允许子类重写方法. 简单的方法是将类声明前 + final
  4. 如果实例变量中有引用类型, 别让他们被更改:

    • 不要提供更改他们的方法
    • 不要共享他们的引用, 包括

      • 不要在构造函数中直接引用参数
      • 不要将实例引用变量返回, 如果不得不, 则, 返回拷贝!
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!