极客时间《Java并发编程实战》----Java线程

主引用自:极客时间《Java并发编程实战》https://time.geekbang.org/column/intro/100023901

2)线程相关问题(必问):
创建线程的四种方式。
什么是线程安全。
Runnable接口和Callable接口的区别。
wait方法和sleep方法的区别。
synchronized、Lock、ReentrantLock、ReadWriteLock。
介绍下CAS(无锁技术)。
什么是 ThreadLocal 。
ThreadPoolExecutor 的内部工作原理。
分布式环境下,怎么保证线程安全。

前言

基本使用

创建线程的四种方式
  1. 继承Thread 类创建线程类
  2. 通过Runnable接口创建线程类
  3. 通过Callable和Future创建线程
  4. 线程池

实现的代码Demo见:创建线程有几种方式?我笑了

比较与区别
  1. 实现Runnable/Callable接口相比继承Thread类的优势
  • 适合多个线程进行资源共享(这个是为什么呐? 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
  • 可以避免java中单继承的限制
  • 增加程序的健壮性,代码和数据独立
  • 线程池只能放入Runable或Callable接口实现类,不能直接放入继承Thread的类
  1. Callable和Runnable的区别
  • Callable重写的是call()方法,Runnable重写的方法是run()方法
  • call()方法执行后可以有返回值,run()方法没有返回值
  • call()方法可以抛出异常,run()方法不可以
  • 运行Callable任务可以拿到一个Future对象,表示异步计算的结果 。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果

一、通用的线程生命周期

在这里插入图片描述

  • 初始状态:,指的是线程已经被创建,但是还不允许分配 CPU 执行。在编程语言层面已经创建,但是真正的操作系统层面还没有创建
  • 可运行状态:操作系统真正创建好了线程,可以被分配CPU执行了
  • 运行状态:线程分配到CPU并执行的状态
  • 休眠状态:当运行状态的线程 调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。(所以sleep就是一直占用CPU,wait就会让出CPU)
  • 终止状态:线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

二、Java中线程的生命周期

Java 线程一共有六种生命状态:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

其中 3. BLOCKED(阻塞状态)4. WAITING(无时限等待)5. TIMED_WAITING(有时限等待)在操作系统层面都是属于休眠模式。

三、Java线程的状态转换

在这里插入图片描述

实际项目中死锁的线程栈例子:
在这里插入图片描述

四、度量程序运行性能指标和多线程意义

  • 时间维度:延迟(发出请求到收到响应这个过程的时间;延迟越短,意味着程序执行得越快,性能也就越好。)
  • 空间维度:吞吐(在单位时间内能处理请求的数量;吞吐量越大,意味着程序能处理的请求越多,性能也就越好。)

对于整个机器而言,其实最主要的还是如何提升CPU 和 I/O 设备综合利用率

在单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。如图所示:
在这里插入图片描述

如果程序只有 CPU 计算,而没有 I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。
比如:需要计算 1+2+… … +100 亿的值,如果在 4 核的 CPU 上利用 4 个线程执行,线程 A 计算[1,25 亿),线程 B 计算[25 亿,50 亿),线程 C 计算[50,75 亿),线程 D 计算[75 亿,100 亿],之后汇总,那么理论上应该比一个线程计算[1,100 亿]快将近 4 倍,响应时间能够降到 25%。一个线程,对于 4 核的 CPU,CPU 的利用率只有 25%,而 4 个线程,则能够将 CPU 的利用率提高到 100%。示意图如下所示:
在这里插入图片描述

五、创建多少个线程合适

就拿上面的计算 1+2+… … +100 亿的值的例子来说,创建了四个线程,性能提升了四倍,那我是不是可以多创建几个线程来达到更快的目的。这样问题就来了,创建多少个合适呐,是不是创建的越多越好?如果是六个线程,那么就意味着其中两个核会产生线程切换,这会带来一定的开销,这样想的话,是不是创建的线程数与CPU核的数目要完全对应上?他们之间有什么样子的关系?

其实创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法都是不同的。

  • 对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

  • 对于 I/O 密集型的计算场景,比如前面我们的例子中,如果 CPU 计算和 I/O 操作的耗时是 1:1,那么 2 个线程是最合适的。如果 CPU 计算和 I/O 操作的耗时是 1:2,那多少个线程合适呢?是 3 个线程,如下图所示:CPU 在 A、B、C 三个线程之间切换,对于线程 A,当 CPU 从 B、C 切换回来时,线程 A 正好执行完 I/O 操作。这样 CPU 和 I/O 设备的利用率都达到了 100%。
    在这里插入图片描述
    从上面可以看出,对于 I/O 密集型的计算场景,在选取线程数目的时候是根据CPU计算与I/O操作的比例而得出的!计算公式是:

单核:最佳线程数 =1 +(I/O 耗时 / CPU 耗时)
多核:最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

当然,在工程上,该比例是很难确定并且动态变化的, 所以就需要压测来大致统计该比例。同时,需要重点关注 CPU、I/O 设备的利用率和性能指标(响应时间、吞吐量)之间的关系。

六、为什么局部变量是线程安全的?

在这里插入图片描述
每个线程都会有自己的独立的调用栈。所有局部变量不会有并发问题。
在这里插入图片描述

七、如何用面向对象思想写好并发程序?

主要有三点:

  1. 从封装共享变量
  2. 识别共享变量间的约束条件
  3. 制定并发访问策略

封装共享变量

将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。例如下面这样:

public class Counter {
  private long value;
  synchronized long get(){
    return value;
  }
  synchronized long addOne(){
    return ++value;
  }
}

对于这些不会发生变化的共享变量,建议你用 final 关键字来修饰。

识别共享变量间的约束条件

例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。


public class SafeWM {
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v){
    // 检查参数合法性
    if (v < lower.get()) {
      throw new IllegalArgumentException();
    }
    upper.set(v);
  }
  // 设置库存下限
  void setLower(long v){
    // 检查参数合法性
    if (v > upper.get()) {
      throw new IllegalArgumentException();
    }
    lower.set(v);
  }
  // 省略其他业务代码
}

我们假设库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线程 B 调用 setLower(7) 将下限设置为 7,如果线程 A 和线程 B 完全同时执行,你会发现线程 A 能够通过参数校验,因为这个时候,下限还没有被线程 B 设置,还是 2,而 5>2;线程 B 也能够通过参数校验,因为这个时候,上限还没有被线程 A 设置,还是 10,而 7<10。当线程 A 和线程 B 都通过参数校验后,就把库存的下限和上限设置成 (7, 5) 了,显然此时的结果是不符合库存下限要小于库存上限这个约束条件的。

那么”正确“的代码应该是:

public class SafeWM {
    // 库存上限
    private final AtomicLong upper =
            new AtomicLong(0);
    // 库存下限
    private final AtomicLong lower =
            new AtomicLong(0);

    // 设置库存上限
    void setUpper(long v) {
        synchronized (this) { //对对象加锁即可!!!
            // 检查参数合法性
            if (v < lower.get()) {
                throw new IllegalArgumentException();
            }
            upper.set(v);
        }
    }

    // 设置库存下限
    void setLower(long v) {
        synchronized (this) {
            // 检查参数合法性
            if (v > upper.get()) {
                throw new IllegalArgumentException();
            }
            lower.set(v);
        }
    }
    // 省略其他业务代码
}

所以一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。

制定并发访问策略

主要有三种方式:

  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

有一些原则在编写这类代码时,建议遵守:

  1. 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
  2. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  3. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
jstack生成的Thread Dump日志.docx 系统线程状态 (Native Thread Status) 系统线程有如下状态: deadlock 死锁线程,一般指多个线程调用期间进入了相互资源占用,导致一直等待无法释放的情况。 runnable 一般指该线程正在执行状态中,该线程占用了资源,正在处理某个操作,如通过SQL语句查询数据库、对某个文件进行写入等。 blocked 线程正处于阻塞状态,指当前线程执行过程中,所需要的资源长时间等待却一直未能获取到,被容器的线程管理器标识为阻塞状态,可以理解为等待资源超时的线程。 waiting on condition 线程正处于等待资源或等待某个条件的发生,具体的原因需要结合下面堆栈信息进行分析。 (1)如果堆栈信息明确是应用代码,则证明该线程正在等待资源,一般是大量读取某种资源且该资源采用了资源锁的情况下,线程进入等待状态,等待资源的读取,或者正在等待其他线程的执行等。 (2)如果发现有大量的线程都正处于这种状态,并且堆栈信息中得知正等待网络读写,这是因为网络阻塞导致线程无法执行,很有可能是一个网络瓶颈的征兆: 网络非常繁忙,几乎消耗了所有的带宽,仍然有大量数据等待网络读写; 网络可能是空闲的,但由于路由或防火墙等原因,导致包无法正常到达; 所以一定要结合系统的一些性能观察工具进行综合分析,比如netstat统计单位时间的发送包的数量,看是否很明显超过了所在网络带宽的限制;观察CPU的利用率,看系统态的CPU时间是否明显大于用户态的CPU时间。这些都指向由于网络带宽所限导致的网络瓶颈。 (3)还有一种常见的情况是该线程在 sleep,等待 sleep 的时间到了,将被唤醒。 waiting for monitor entry 或 in Object.wait() Moniter 是Java中用以实现线程之间的互斥与协作的主要手段,它可以看成是对象或者class的锁,每个对象都有,也仅有一个 Monitor。 从上图可以看出,每个Monitor在某个时刻只能被一个线程拥有,该线程就是 "Active Thread",而其他线程都是 "Waiting Thread",分别在两个队列 "Entry Set"和"Waint Set"里面等待。其中在 "Entry Set" 中等待的线程状态是 waiting for monitor entry,在 "Wait Set" 中等待的线程状态是 in Object.wait()。 (1)"Entry Set"里面的线程。 我们称被 synchronized 保护起来的代码段为临界区,对应的代码如下: synchronized(obj){} 当一个线程申请进入临界区时,它就进入了 "Entry Set" 队列中,这时候有两种可能性: 该Monitor不被其他线程拥有,"Entry Set"里面也没有其他等待的线程。本线程即成为相应类或者对象的Monitor的Owner,执行临界区里面的代码;此时在Thread Dump中显示线程处于 "Runnable" 状态。 该Monitor被其他线程拥有,本线程在 "Entry Set" 队列中等待。此时在Thread Dump中显示线程处于 "waiting for monity entry" 状态。 临界区的设置是为了保证其内部的代码执行的原子性和完整性,但因为临界区在任何时间只允许线程串行通过,这和我们使用多线程的初衷是相反的。如果在多线程程序中大量使用synchronized,或者不适当的使用它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在Thread Dump中发现这个情况,应该审视源码并对其进行改进。 (2)"Wait Set"里面的线程线程获得了Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(通常是被synchronized的对象)的wait()方法,放弃Monitor,进入 "Wait Set"队列。只有当别的线程在该对象上调用了 notify()或者notifyAll()方法,"Wait Set"队列中的线程才得到机会去竞争,但是只有一个线程获得对象的Monitor,恢复到运行态。"Wait Set"中的线程在Thread Dump中显示的状态为 in Object.wait()。通常来说, 通常来说,当CPU很忙的时候关注 Runnable 状态的线程,反之则关注 waiting for monitor entry 状态的线程。 JVM线程运行状态 (JVM Thread Status)

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值