多线程介绍

Why 并发编程?

CPU通过时间片分配算法来执行指令。有关Java并发编程,以下是减少上下文切换的工具:

  • 使用性能分析工具Lmbench3测量上下文切换时长

  • 使用vmstat观察上下文切换次数

  • 通过无锁并发编程、CAS算法、协程等方式进行优化。

Java线程简述

何谓线程

现代操作系统在运行一个程序时,会为其创建一个进程,在一个进程里可以创建多个线程。线程是现代操作系统调度的最小单元。

现代操作系统基本采用时分的形式调度运行的线程,线程会分配到若干时间片,时尽则调度,时间片多少也就决定了线程使用处理器资源的多少。而线程优先级就是决定线程需要的处理器资源的线程属性,在 Java 线程中,通过一个整型成员变量 priority 来控制(范围从1 ~ 10),在线程构建的时候可以通过 setPriority(int) 方法来修改。

线程优先级不能作为程序正确性的依赖,因为操作系统可以不理会 Java 线程对于优先级的设定 。

Thread类中的start方法可以启动新线程,同时调用run方法。

Runnable接口实现了run方法,供方法的调用。

线程的构造和启动

Daemon 线程是一种支持型线程,主要被用作程序中后台调度以及支持性工作,可设置。

线程构造

运行线程前首先要构造一个线程对象,线程对象在构造的时候需要提供线程所需要的属性,如线程所属的线程组、线程优先级、是否是 Daemon 线程等信息 。

建议在线程启动前,为这个线程设置线程名称,便于调试。

线程启动

  • 利用Thread子类的实例(继承)

    创建Thread的子类,并利用它来启动。如new aThread().start()

    重写run方法,调用start方法创建线程。如果直接调用run方法,会在主线程运行。

  • 利用Runnable接口

    调用Runnable接口的实现run方法的类。这种方式相较于继承,便于资源共享。如new Thread(new aRunner()).start();

-- 上面两种方法都是无返回值的,若需要返回值,则:

  • 实现Callable 接口,重写call方法,通过FutureTask获取返回值(参:机制)

此外,通过线程池(Executors)也能创建线程。

线程的暂停

sleep方法是Thread类的静态方法,注意由Thread调用。

线程的互斥与协作

synchronized方法

同步方法,每次只能由一个线程运行,其获取了一个防止其他线程进入的锁。

synchronized的方法不受锁的影响。

每个实例拥有一个独立的锁。

synchronized代码块

synchronized(expr){...}可以使方法中的某一部分由一个线程进行而非整个方法。

synchronized静态方法

synchronized静态方法也是每次只能由一个线程运行,但其锁机制比较特别:使用类对象的锁执行互斥处理。

同一时刻只能有一个线程获取到由 synchronized 所保护对象的监视器 。

线程的协作

下面三个方法均是Object类的方法:

  • (this.)wait:当前线程暂停运行并进入调用者obj的等待队列中,释放锁。

  • notify:唤醒等待队列中的其中一个(未定义)线程。

  • notifyAll:唤醒等待队列中的所有线程。推荐使用。

这里有一些建议:

  • 使用 wait、notify和 notifyAll时先对调用对象加锁 。

  • notify 或 notifyAll 方法调用后,等待线程依旧不会从 wait 返回,即,从 wait 方法返回的前提是获得了调用对象的锁 。需要调用notify() 或 notifyAll() 的线程释放锁之后,等待线程才有机会从 wait返回。

等待 - 通知模式

  • 等待方:

  • 通知方

等待-超时模式

在等待-通知范式下略作修改,得到“超时则返回默认值”的模式。

join方法

如果一个线程 A 执行了 thread.join() 语句 , 其含义是 :当前线程 A 等待 thread 线程终止后才从thread .join()返回。

线程的状态迁移

from:图解Java多线程设计模式

习题

Java内存模型 - 多线程篇

并发编程两大关键问题:线程间如何通信?线程间如何同步?

JMM通过控制主内存与每个线程的本地内存之间的交互,来进行可见性保证。

内存屏障

Java编译器会在生成指令序列的特定位置设置屏障,以禁止特定类型的处理器重排。

最强屏障:StoreLoad

根据顺序一致性模型,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

Happens-before机制

happens-before是JMM中最核心的概念。

JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

  • 传递性:如果A happens-before B,且 B happens-before C,那么A happens-before C。

允许结果一致的重排
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

可见性

可见性:线程A对字段x写入值,线程B可读到该值,则“线程A向x的写值对线程B是可见的”。

多线程程序中,如果没有使用synchronizedvolatile正确同步,线程A向字段的写值对线程B不是立即可见的

内存模型综述

Java 程序的内存可见性保证可以分为下列 3 类 。

  • 单线程程序不会出现内存可见性问题 。

  • 正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同) 。

  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值( 0/null/false) 。

内存语义

volatile的内存语义

具有“同步处理”和“对 long 和 double 进行原子操作”两大功能。

简而言之,volatile变量自身具有下列特性。

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入

  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

某个线程对volatile字段的写操作结果对其他线程立即可见。volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

写-读

volatile读的内存语义如下:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

可以通过设计内存屏障的方式,实现内存语义,举例:

volatile 可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性 。

注意:volatile不会进行线程的互斥处理。

JDK 5后,严格限制 volatile 变量与普通变量的重排序,使 volatile 的写-读和锁的释放 - 获取具有相同的内存语义。

锁的内存语义

我们来看一个例:

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

配合上述内存语义,结合AQS(非阻塞数据结构和原子变量类),助力concurrent包实现。

final的内存语义

final字段初始化的值对所有线程可见。

对于final域,编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写final域:JMM禁止编译器把final域的写重排序到构造函数之外。编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。

确保构造函数处理结束时,字段值被正确初始化。

读final域:在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作的前面插入一个LoadLoad屏障。

如果final为引用类型呢?对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

JDK5增强 final 的内存语义:保证 final 引用不会从构造函数内逸出的情况下,final 具有初始化安全性 。

synchronized

相当于{ 时获取锁,}时释放锁。

Java内存模型确保某个线程在进行unlock M前的所有写入操作对执行lock M的进程可见

关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性 。

多线程程序的评价标准

  • 安全性:不损坏对象

    如果一个类被多线程同时调用,会导致对象的状态与设计者不一致,则为线程不安全的。

    如果非线程安全者经过适当的互斥处理,则称为线程兼容。

  • 生存性:必要的处理能够被执行

    死锁就是一个典型的反例。

  • 可复用性:类可重复调用、利用。

  • 性能:能快速、大批量进行处理。

    影响性能的因素有:

    • 吞吐量:单位时间内能完成的处理数量。

    • 响应性:发出请求到收到响应的间隔。

    • 容量:可同时进行的处理数量。

安全性和生存性是必须遵守的标准,在此基础上提高可复用性和性能。


参考: 《图解Java多线程设计模式》

最后更新于

这有帮助吗?