多线程编程指南核心篇笔记

Java多线程编程实战指南 核心篇

Thread类的start方法作用是启动相应的线程。启动一个线程的实质是请求Java虚拟机运行相应的线程,而这个线程具体何时才能执行是由线程调度器(Scheduler)决定的。因此,start方法调用结束并不意味着相应线程已经开始运行,这个线程可能稍后才被运行,甚至也可能永远不会被运行。

Thread类有两个构造方法:Thread()和Thread(Runnable target).对应Thread的创建也有两种方式:
①新建Thread子类,实现run方法
②创建一个Runnable,实现run方法,传到Thread中
每个线程都有自己的名字,使用Thread.currentThread().getName()可以获取当前线程的名字。

无论是采用哪个方式创建线程,一旦线程的run方法执行结束(run方法由Java虚拟机调用)结束,相应的线程的运行也就结束了。
运行结束的线程所占用的资源会被java虚拟机垃圾回收。线程属于一次性用品,跟TT一样,不能通过调用一个已经运行结束的线程的start方法来重新运行。start方法只能被调用一次,多次调用同一个Thread实例的start方法会抛出IllegalThreadStateException异常。(———————————待测试实例!!!!!!!!)


image.png

创建一个线程与创建其他类型的Java对象不同的是,Java虚拟机会为每个线程分配调用栈(Call Stack)所需的内存空间。调用栈主要用于跟踪Java代码间的调用关系以及Java代码对本地代码(通常是C代码)的调用.每个线程可能还有一个内核线程与之对应。(具体与java虚拟机的实现有关)
Java平台中的任意一段代码,比如一个方法,总是由确定的线程负责执行的,这个线程就响应地被称为这段代码的执行线程。同一个段代码可以被多个线程执行。
线程的run方法总是由Java虚拟机直接调用,但因为其是一个public方法,所以我们也可以直接调用。但是多数情况下我们不能这么做,因为我们手动调用了,java虚拟机也会调用一次,运行两次,如果我们没有启动线程而是在应用代码中直接调用现车航的run方法的话,这个线程的run方法会运行在当前线程之中而不是运行在自身线程中,这有违创建线程的初衷。

Runnable接口

Thread类实际上是Runnable接口的一个实现类

Thread类中的run方法中的逻辑是target不为null,就调用target.run(),否则什么也不做,target类型是Runnable。
如果是通过构造器Thread(Runnable target)创建的,那么target的值为构造器中的参数值,否则target的值为null。
所以Thread类的任务处理逻辑是要么什么也不做(target为null),要么执行target也就是Runnable实例所实现的任务处理逻辑。

Thread的两种实现方式:继承(创建Thread的子类)、组合(构造函数中传入Runnable对象)。组合相对继承来说,其类和类之间的耦合性更低,也更加灵活,所以组合有优先选用的技术。

线程属性

线程的属性包括:

线程的编号(ID)
名称(NAME)
线程类别(Daemon)
优先级(Priority)

编号
Long类型。用于标识不同的线程。不同的线程有不同的编号。某个编号的线程运行结束后,该编号可能被后续创建的线程使用。不适合用作某种唯一标识。只读,不可设置。
名称
String类型。面向人的一个属性。默认值的格式为”Thread-线程编号”,如”Thread-0”.
多个线程可以有相同的名字,但为了方便问题定位和调试,尽量不要这么做。
线程类别
Boolean类型。值为true表示相应的线程为守护线程,否则表示相应的线程为用户线程。必须在相应线程启动之前设置,即setDaemon方法的调用必须在对start方法的调用之前。负责一些关键任务处理的线程不适宜设置为守护线程。

一个Java虚拟机只有在所有的用户线程都运行结束后,也就是Thread.run方法调用结束的情况下才能正常结束。而守护线程则不会影响java虚拟机的正常停止,即应用程序中有守护线程在运行也不影响java虚拟机的正常停止。因此守护线程通常用于执行一些重要性不是很高的任务,例如用于监视其他线程的运行情况。

优先级
Int类型。1~10个优先级。默认值为5(普通优先级).对于一个具体线程而言,其优先级的默认值与其父线程的优先级值相等。一般采用默认优先级即可,不恰当的设置该属性可能导致严重的问题(线程饥饿)。
需要注意的是线程的优先级只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行。它并不能保证线程按照优先级高低的顺序运行。优先级使用不当或者滥用可能导致某些线程永远无法得到执行,即产生线程饥饿。因此,线程的优先级并不是越高越好。

Thread常用方法

image.png

join方法的作用相当于执行该方法的线程和线程调度器说:我得先暂停一下,等另外一个线程运行结束后才能继续干活。后面第五章有详解。
yield静态方法的作用相当于执行该方法的线程对线程调度器说:我现在不急,如果别人需要处理资源的话先给他用吧。当然,如果没有其他人要用,我也不介意继续占用。
sleep静态方法相当于执行该方法的线程对线程调度器说:我想小憩一会儿,果断事件再叫醒我继续干活吧。使用sleep静态方法可以实现一个简易的倒计时器。
while true的循环中,sleep 1秒,倒计时即可。

无处不在的线程

除了开发人员自己创建和使用的线程(即Thread类或子类的实例),java平台汇总其他由java虚拟机创建、使用的线程也随处可见。任何一段代码都是执行在某个线程中的。java虚拟机启动的时候会创建一个main线程,该新城负责执行java程序的入口方法。(main方法)。

线程的层次结构

线程A创建B,我们习惯称B是A的子线程。B也可能创建其他的线程。所以,父线程、子线程是一个相对的称呼。
一个线程是否是守护线程取决于其父线程。默认情况下,父线程是守护线程,则子线程也是守护线程;父线程是用户线程,则子线程也是用户线程,父线程可以在创建子线程中启动子线程之前调用该线程的setDaemon方法,将子线程改为守护线程或者用户线程。
java平台中没有API用户获取一个线程的父线程,或者或一个线程的所有子线程。并且父线程和子线程之间的生命周期也没有必然的联系。比如父线程运行结束后,子线程也可以继续运行,子线程结束也不妨碍父线程继续运行。
我们同城也称某些线程为工作者线程(Worker Thread)或者后台线程(Background Thread)。工作者线程通常是其父线程创建来用于专门负责某项特定任务的执行的。比如GC工作者线程。

线程的生命周期
image.png

Java线程的状态可以使用监控工具 查看,也可以通过Thread.getState()调用来获取。Thread.getState()的返回值类型Thread.State是一个枚举类型(Enum).
Thread.State所定义的线程状态包括以下几种:
①NEW:一个已经创建而未启动的线程处于该状态。由于一个线程只能够被启动一次,所以一个线程只可能有一次处于该状态。
②RUNNABLE:该状态可以被看做一个复合状态。包括两个子状态:READY和RUNNING.前者表示处以该状态的线程可以被线程调度器进行调度而处于RUNNING状态。后者表示处于该状态的线程正在运行,即相应线程对象的run'方法所对应的指令正在由处理器执行。执行Thread.yield()的线程,其状态可能会由RUNNING转换为READY.处于READY子状态的线程也被成为活跃线程。
③BLOCKED:发起一个阻塞时I/O操作后,或者申请一个由其他线程持有的独占资源时,相应的线程会处于该状态。处于该状态的线程不会占用处理器资源。当阻塞式I/O操作完成后,或者线程获得了其申请的资源,该线程的状态又可以转换为RUNNABLE.
④WATING:Object.wait()/Thread.join()和LockSupport.park(Object)可以使其变为此状态。使其变为RUNNABLE的相应方法包括Object.notify()/notifyAll()和LockSupport.unpark(Object)
⑤TIMED_WATING:和上面的类似,只不过这个非无限制的等待,有时间限制。
⑥TERMINATED:已经结束的线程处于该状态。一个线程只能有一次处于该状态。
一个线程在其整个生命周期中,只可能you多线程编程实战指南 核心篇

Thread类的start方法作用是启动相应的线程。启动一个线程的实质是请求Java虚拟机运行相应的线程,而这个线程具体何时才能执行是由线程调度器(Scheduler)决定的。因此,start方法调用结束并不意味着相应线程已经开始运行,这个线程可能稍后才被运行,甚至也可能永远不会被运行。

Thread类有两个构造方法:Thread()和Thread(Runnable target).对应Thread的创建也有两种方式:
①新建Thread子类,实现run方法
②创建一个Runnable,实现run方法,传到Thread中
每个线程都有自己的名字,使用Thread.currentThread().getName()可以获取当前线程的名字。

无论是采用哪个方式创建线程,一旦线程的run方法执行结束(run方法由Java虚拟机调用)结束,相应的线程的运行也就结束了。
运行结束的线程所占用的资源会被java虚拟机垃圾回收。线程属于一次性用品,跟TT一样,不能通过调用一个已经运行结束的线程的start方法来重新运行。start方法只能被调用一次,多次调用同一个Thread实例的start方法会抛出IllegalThreadStateException异常。(———————————待测试实例!!!!!!!!)

创建一个线程与创建其他类型的Java对象不同的是,Java虚拟机会为每个线程分配调用栈(Call Stack)所需的内存空间。调用栈主要用于跟踪Java代码间的调用关系以及Java代码对本地代码(通常是C代码)的调用.每个线程可能还有一个内核线程与之对应。(具体与java虚拟机的实现有关)
Java平台中的任意一段代码,比如一个方法,总是由确定的线程负责执行的,这个线程就响应地被称为这段代码的执行线程。同一个段代码可以被多个线程执行。
线程的run方法总是由Java虚拟机直接调用,但因为其是一个public方法,所以我们也可以直接调用。但是多数情况下我们不能这么做,因为我们手动调用了,java虚拟机也会调用一次,运行两次,如果我们没有启动线程而是在应用代码中直接调用现车航的run方法的话,这个线程的run方法会运行在当前线程之中而不是运行在自身线程中,这有违创建线程的初衷。

Runnable接口

Thread类实际上是Runnable接口的一个实现类

Thread类中的run方法中的逻辑是target不为null,就调用target.run(),否则什么也不做,target类型是Runnable。
如果是通过构造器Thread(Runnable target)创建的,那么target的值为构造器中的参数值,否则target的值为null。
所以Thread类的任务处理逻辑是要么什么也不做(target为null),要么执行target也就是Runnable实例所实现的任务处理逻辑。

Thread的两种实现方式:继承(创建Thread的子类)、组合(构造函数中传入Runnable对象)。组合相对继承来说,其类和类之间的耦合性更低,也更加灵活,所以组合有优先选用的技术。

线程属性

线程的属性包括:

线程的编号(ID)
名称(NAME)
线程类别(Daemon)
优先级(Priority)

编号
Long类型。用于标识不同的线程。不同的线程有不同的编号。某个编号的线程运行结束后,该编号可能被后续创建的线程使用。不适合用作某种唯一标识。只读,不可设置。
名称
String类型。面向人的一个属性。默认值的格式为”Thread-线程编号”,如”Thread-0”.
多个线程可以有相同的名字,但为了方便问题定位和调试,尽量不要这么做。
线程类别
Boolean类型。值为true表示相应的线程为守护线程,否则表示相应的线程为用户线程。必须在相应线程启动之前设置,即setDaemon方法的调用必须在对start方法的调用之前。负责一些关键任务处理的线程不适宜设置为守护线程。

一个Java虚拟机只有在所有的用户线程都运行结束后,也就是Thread.run方法调用结束的情况下才能正常结束。而守护线程则不会影响java虚拟机的正常停止,即应用程序中有守护线程在运行也不影响java虚拟机的正常停止。因此守护线程通常用于执行一些重要性不是很高的任务,例如用于监视其他线程的运行情况。

优先级
Int类型。1~10个优先级。默认值为5(普通优先级).对于一个具体线程而言,其优先级的默认值与其父线程的优先级值相等。一般采用默认优先级即可,不恰当的设置该属性可能导致严重的问题(线程饥饿)。
需要注意的是线程的优先级只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行。它并不能保证线程按照优先级高低的顺序运行。优先级使用不当或者滥用可能导致某些线程永远无法得到执行,即产生线程饥饿。因此,线程的优先级并不是越高越好。

Thread常用方法

image.png

join方法的作用相当于执行该方法的线程和线程调度器说:我得先暂停一下,等另外一个线程运行结束后才能继续干活。后面第五章有详解。
yield静态方法的作用相当于执行该方法的线程对线程调度器说:我现在不急,如果别人需要处理资源的话先给他用吧。当然,如果没有其他人要用,我也不介意继续占用。
sleep静态方法相当于执行该方法的线程对线程调度器说:我想小憩一会儿,果断事件再叫醒我继续干活吧。使用sleep静态方法可以实现一个简易的倒计时器。
while true的循环中,sleep 1秒,倒计时即可。

无处不在的线程

除了开发人员自己创建和使用的线程(即Thread类或子类的实例),java平台汇总其他由java虚拟机创建、使用的线程也随处可见。任何一段代码都是执行在某个线程中的。java虚拟机启动的时候会创建一个main线程,该新城负责执行java程序的入口方法。(main方法)。

线程的层次结构

线程A创建B,我们习惯称B是A的子线程。B也可能创建其他的线程。所以,父线程、子线程是一个相对的称呼。
一个线程是否是守护线程取决于其父线程。默认情况下,父线程是守护线程,则子线程也是守护线程;父线程是用户线程,则子线程也是用户线程,父线程可以在创建子线程中启动子线程之前调用该线程的setDaemon方法,将子线程改为守护线程或者用户线程。
java平台中没有API用户获取一个线程的父线程,或者或一个线程的所有子线程。并且父线程和子线程之间的生命周期也没有必然的联系。比如父线程运行结束后,子线程也可以继续运行,子线程结束也不妨碍父线程继续运行。
我们同城也称某些线程为工作者线程(Worker Thread)或者后台线程(Background Thread)。工作者线程通常是其父线程创建来用于专门负责某项特定任务的执行的。比如GC工作者线程。

线程的生命周期
image.png

Java线程的状态可以使用监控工具 查看,也可以通过Thread.getState()调用来获取。Thread.getState()的返回值类型Thread.State是一个枚举类型(Enum).
Thread.State所定义的线程状态包括以下几种:
①NEW:一个已经创建而未启动的线程处于该状态。由于一个线程只能够被启动一次,所以一个线程只可能有一次处于该状态。
②RUNNABLE:该状态可以被看做一个复合状态。包括两个子状态:READY和RUNNING.前者表示处以该状态的线程可以被线程调度器进行调度而处于RUNNING状态。后者表示处于该状态的线程正在运行,即相应线程对象的run'方法所对应的指令正在由处理器执行。执行Thread.yield()的线程,其状态可能会由RUNNING转换为READY.处于READY子状态的线程也被成为活跃线程。
③BLOCKED:发起一个阻塞时I/O操作后,或者申请一个由其他线程持有的独占资源时,相应的线程会处于该状态。处于该状态的线程不会占用处理器资源。当阻塞式I/O操作完成后,或者线程获得了其申请的资源,该线程的状态又可以转换为RUNNABLE.
④WATING:Object.wait()/Thread.join()和LockSupport.park(Object)可以使其变为此状态。使其变为RUNNABLE的相应方法包括Object.notify()/notifyAll()和LockSupport.unpark(Object)
⑤TIMED_WATING:和上面的类似,只不过这个非无限制的等待,有时间限制。
⑥TERMINATED:已经结束的线程处于该状态。一个线程只能有一次处于该状态。
一个线程在其整个生命周期中,只可能有一次处于NEW和TEMMINATED状态。

本章总结

image.png

多线程编程目标与挑战

主要讲一些多线程编程中的概念和存在的问题,以便引入多线程编程的各种方式。

串行、并发、并行

串行:
多个任务按一定顺序执行,一个任务执行完成后才能执行下个任务。
并行:
并发
就是在一段时间内以交替的方式去完成多个任务。因为处理器可以使用时间片分派的技术实现在同一个时间内运行多个线程,因此单个处理器就可以实现并发。
并行
以齐头并进的方式去完成多个任务。需要多个处理器在同一个时刻各自运行一个线程来实现。

并发与并行的关系
并行是一种更为严格、理想的并发,即并行可以被看作是并发的一个特例。并发往往是带有部分串行的并发(多个车辆行驶在多个车道上,但如果在施工阶段,只开一个车道的话,只能鱼贯而入采用串行方式了),而并发的极致就是并行(Parallel)。

竞态

多线程编程中经常遇到的一个问题就是对于同样的输入,程序的输出有时候是正确的,有时候却是错误的。这种一个计算结果的正确性与时间相关的现象就被称为竞态。
导致竞态的常见因素是多个线程在没有采取任何控制措施的情况下并发地更新、读取同一个共享变量

比如我们可以新增一个int类型全局变量,开启四个线程,同时自增这个变量,那么就可能有线程拿到相同的值。(待贴上实际操作图!!!!!!!)

竞态往往伴随着读取脏数据问题,即线程读取都爱一个过时的数据、丢失更新问题。即一个线程对数据所做的更新没有体现在后续其他线程对该数据的读取上。
竞态并不一定就导致计算结果的不正确,毕竟这需要often,不属于always。

竞态的两种模式
1.read-modify-write(读-改-写)
2.check-then-act(检测而后行动)

read-modify-write
该操作可以拆分步骤为:
读取一个共享变量的值(read),根据该值做一些计算(modify),接着更新该共享变量的值(write).
ex:一个全局的共享变量sequence++,就是read-modify-write模式。相当于如下几个指令的组合:
①指令read:从内存将sequence的值读取到寄存器r1(读取一个共享变量的值)
②指令modify:将寄存器r1的值增加1(根据共享变量做一些计算)
③指令write:将寄存器r1的内容写入sequence对应的内存空间(更新共享变量)

check-then-act(检测而后行动)
该操作可以拆分步骤为:
读取某个共享变量的值,根据该变量的值决定下一步的动作是什么。
ex:

if (sequenct >= 999) { //子操作①check:检测共享变量的值
  sequence = 0;//子操作②act:下一个的操作
} else {
  sequenct++;
}

这里的if-else就是一个典型的check-then-act
一个线程在执行完子操作①到开始执行子操作②的这段时间内,其他线程可能已经更新了共享变量的值,而使得if语句中的条件变为不成立,那么此时该线程仍然会执行子操作②,尽管这个子操作所需的前提(if语句中的条件)实际上并未成立!
从上面的分析中我们可以总结出竞态产生的一般条件:
设O1和O2是并发访问共享变量V的两个操作,这两个操作并非都是读操作。如果一个线程在执行O1期间(开始执行并未执行结束),另外一个线程正在执行O2,那么无论O2是在读取还是更新V都会导致竞态的产生。
对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程访问的是各自的那一部分局部变量,因此局部变量的使用不会导致竞态
synchronized关键字会使其修饰的方法在任一时刻只能够被一个线程执行,这使得该方法涉及的共享变量在任一时刻只能够有一个线程访问(读、写),从而避免了这个方法的交错执行而导致的干扰,这样就消除了竞态。

线程安全性

一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,再其使用方不必为其作出任何改变的情况下也能运作正常,我们就称其为线程安全的。反之,如果单线程正常,多线程下无法正常运作,则为非线程安全的。简言之:一个类如果能够导致竞态,那么它就是非线程安全的;而如果一个类是线程安全的,那么它就不会导致竞态。在多线程环境下使用一个类的时候,一定要搞明白此类是否是线程安全的!

原子性

对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地称该操作具有原子性。
这里的"不可分割",其中一个含义是指访问(读,写)某个共享变量的操作从其他执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会"看到"该操作执行了部分的中间效果。

理解原子操作必须注意以下两点:
1.原子操作是针对访问共享变量的操作而言的。对于仅涉及局部变量访问的操作无所谓是否是原子的,或者干脆把这一类操作都看作是原子操作。
2.原子操作是从该操作的执行线程之外的线程来描述的,它只有在多线程环境下才有意义!!

原子操作的”不可分割”包括以下两层含义:

1.访问(读、写)某个共享变量的操作从其他线程来看,该操作要么已经执行结束,要么尚未发生。即其他线程不会”看到”该操作执行了部分的中间效果。
2.访问同一组共享变量的原子操作是不能够被交错的

java实现原子性的两种方式
①使用锁(Lock).锁具有排他性,即它能保障一个共享变量在任意一个时刻只能被一个线程访问。
②CAS(Compare-and-Swap)指令:CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常在软件层次实现,而CAS在硬件(处理器和内存)这一层次实现,可以看作是"硬件锁".
在java中,long型和double型以外的任何基础类型的变量的写操作都是原子操作,即对byte/boolean/short/char/float/int的变量和引用型变量的写操作都是原子的,这点由java虚拟机具体实现。
long和double要想实现写操作的原子性,需要使用volatile关键字修饰。这里有一点需要注意:volatile关键字仅能够保障变量写操作的原子性,它并不能保障其他操作,比如read-modify-write操作和check-then-act操作的原子性!后面我们会详细介绍。
原子操作+原子操作 复合操作所得到的并非是原子操作。
比如 a=0;b=1;对这两者的增减都是原子操作。但可能有线程已经修改了a,还未修改b,其他线程在此时读取了a和b,拿到了中间结果!有违原子操作不可分割的特性。

可见性

在多线程环境下,一个线程对某个变量进行更新后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性(Visibility)。
可见性就是指一个线程对共享变量更新的结果对于读取相应共享变量的线程而言是否可见的问题。
可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器(Register)而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器的内容。因此,如果两个线程分别运行在不同的处理器上,而这个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。而就算某个共享变量被分配到主内存中,也不能保证该变量的可见性。这是因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存(Cache)子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器(Store Buffer)中,还没有到达该处理器的高速缓存中,更不用说到主内存中了。而一个处理器的写缓冲器的内容跟寄存器一样,无法被另外一个处理器读取,因此运行在另外一个处理器上的线程无法看到这个线程对某个共享变量的更新。
而就算这个共享变量的更新结果被写入该处理器的高速缓存中,由于该处理器将这个变量更新的结果通知给其他处理器的时候,其他处理器可能仅仅将这个更新通知的内容存入无效化队列(Invalidate Queue)中,导致从相应处理器的高速缓存中读取到的变量值是一个过时的值。
处理器并不直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器(Register)、高速缓存(Cache)、写缓冲器(Store Buffer,也称Write Buffer)和无效化队列等部件执行内存的读写操作。从这个角度来看,这些部件相当于主内存的副本,简称处理器缓存。
虽然一个处理器的高速缓存中的内容不能被另外一个处理器直接读取,但是一个处理器可以通过缓存一致性协议来读取其他处理器的高速缓存中的数据,并将读到的数据更新到该处理器的高速缓存中,这就是缓存同步。我们称这些部件的内容是可同步的,这些存储组件包括处理器的高速缓存、主内存。(寄存器不行吗????)
一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器就必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为 刷新处理器缓存。
在java平台中可以使用volatile关键字修饰变量,会使相应的处理器执行刷新处理器缓存的动作,保障了可见性。
可见性得以保障,并不意味着一个线程能够看到另外一个线程更新的所有变量的值。比如一个线程在某个时刻同时更新了多个共享变量的值,此后其他线程读取到的可能是更新过的,也可能是更新之前的值。

可见性与原子性的联系与区别
①原子性描述的是一个线程对共享变量的更新,从另外一个线程角度来看,它要么完成了,要么尚未繁盛,而不是进行中的一个状态。可以保证一个线程读取到的共享变量的值要么是该变量的初始值,要么是更新后的值,而不是一个中间状态的值。
②可见性描述的一个线程对共享变量的更新对于另外一个线程而言是否可见的问题。保障可见性意味着一个线程可以读取到相应共享变量的相对新值。

因此,从保障线程安全的角度来看,光保障原子性可能是不够的的,还需要同时保证可见性。

有序性

有序性(Ordering)指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程来看是乱序的。所谓乱序,是指内存访问的操作的顺序看起来像是发生了什么变化。

处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫做重排序。
重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能。但是它可能对多线程程序的正确性产生影响,即可能导致线程安全问题。与可见性问题类似,重排序也不是必然出现的。

重排序的潜在来源有许多,包括编译器(在Java平台基本上指的就是JIT编译器)、处理器和存储子系统(包括写缓冲器Store Buffer、高速缓存Cache)。这里先定义几个概念,方便后面理解:

image.png

我只记住程序顺序和执行顺序吧...
在此基础上,重排序可划分为:指令重排序、存储子系统重排序
image.png

指令重排序

在源代码程序与执行顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序。指令重排序是一种动作,它确确实实地对指令的顺序做了调整,指令重排序的对象是指令。
java平台包含两种编译器:
静态编译器(javac)和动态编译器(JIT编译器)。前者的作用是将Java源代码(.java文件)编译成字节码(.class二进制文件),它是在代码编译阶段介入的。后者的作用是将字节码动态编译为Java虚拟机宿主机的本地代码(机器码),它是在Java程序运行过程中介入的。
在java平台中,静态编译器(javac)基本上不会执行指令重排序,而JIT编译器则可能执行指令重排序。
现代处理器为了提高指令执行效率,往往不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令,这就是处理器的乱序执行。** 指令执行的结果会被先存入重排序缓冲器,而不是直接被写入寄存器或者主内存。重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交到寄存器或者内存中去,顺序提交,所以即使指令的执行顺序可能没有完全按照程序顺序,而指令执行结果的提交(即反映到寄存器和内存中)仍然是按照程序顺序来得,因为处理器的指令重排序并不会对单线程程序的正确性产生影响。**【指令重排不影响单线程】

存储子系统重排序

主内存(RAM)相对于处理器来说是一个慢速设备。为了避免其拖后腿,处理器并不是直接访问主内存,而是通过高速缓存(Cache)访问主内存。在此基础上,现代处理器还引入了写缓冲器(Store Buffer,也称Write Buffer)以提高高速缓存的效率。有的处理器(比如Intel 的x86处理器)对所有的写主内存的操作都是通过写缓冲器进行的。我们将写缓冲器和高速缓存统称为存储子系统,也就是处理器的子系统。
即使处理器严格依照程序顺序执行两个内存访问操作的情况下,在存储子系统的作用下其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,即这两个操作的执行顺序看起来像是发生了变化。这种现象就是存储子系统重排序,也被称为内存重排序。
指令重排序的重排序对象是指令,它实实在在地对指令的顺序进行调整,而存储子系统重排序是一种现象而不是一种动作, 它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的顺序执行像是被调整过一样的线程,其重排序的对象是内存操作的结果。
从处理器的角度来说,读内存操作的实质是从指定的RAM地址加载数据(通过高速内存)到寄存器,因此读内存操作通常被称为Load,写内存操作的实质是将数据存储到指定地址表示的RAM存储单元中,因此写内存操作通常被称为Store。所以内存重排序只有一下四种可能:

image.png

image.png

貌似串行语义

重排序并非随意地对指令、内存操作的结果进行杂乱无章的排序或者顺序调整,而是遵循一定的规则。编译器(主要为JIT编译器)、处理器都会遵守这些规则,从而给单线程程序创造一种假象---指令是按照源代码顺序执行的。这种假象就叫做貌似串行语义貌似串行语义只是从单线程程序的角度保证重排序后的运行结果不影响程序的正确性,它并不保证多线程环境下程序的正确性。

保证内存访问的顺序性

貌似串行语义只是保障了重排序不影响单线程程序的正确性。从这个角度出发,有序性的保证可以理解为通过某种措施使得貌似串行语义扩展到多线程程序,即重排序要么不发生,要么即使发生了也不会影响多线程程序的正确性。有序性的保证可以理解为从逻辑上禁止重排,并不意味着从屋里上禁止重排序而使得处理器完全依照源代码顺序执行指令,那么做的话效率会很低!
而volatile关键字、synchronized关键字都能够实现有序性。

可见性与有序性的联系与区别
可见性是有序性的基础。可见性描述的是一个线程对共享变量的更新对于另外一个线程是否可见,或者说什么情况下可见的问题。有序性描述的是,一个处理器上运行的线程对共享变量所做的更新,在其他处理器上运行的其他线程来看,这些线程观察到的顺序是怎么样的。所以,可见性是有序性的基础。不然其他线程都不知道数据更新了,又怎么知道是以什么样的顺序更新的呢。

上下文切换【重点哦】

上下文切换在某种程序上可以被看作多个线程共享同一个处理器的产物,它是多线程编程中的一个重要概念!

单处理器上的多线程其实就是通过时间片分配的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。但当一个进程中的一个线程由于其时间片用完或者其自身的原因暂停运行时,另外一个线程(可能是同一个进程或者其它进程的线程)可以被线程调度器选中占用处理器开始或者继续其运行。这种一个线程被暂停,被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程就叫做线程上下文切换。 跟小孩子抢玩具一样,一个玩具,小明玩一会,小红玩,小红玩完,小白玩。
一个线程被剥夺处理器的使用权而被暂停运行被称为切出;一个线程被线程调度器选中占用处理器开始或者继续其运行就被称为切入。
看似连续运行的线程,实际上是以断断续续运行的方式进展的。这种方式意味着切出和切入的时候操作系统需要保存和恢复相应线程的进度信息,即切入和切出那一刻相应线程所执行的任务进行到什么程度了(如计算的中间结果以及执行到了哪条指令)。这个进度信息就被称为上下文。它一般包括通用寄存器的内容和程序计数器的内容。
从java的角度来看,一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态(包括BLOCKED、WAITING和TIMED_WAITING中的任意一个子状态)之间切换的过程就是一个上下文切换的过程。一个线程的生命周期状态由非RUNNABLE状态进入RUNNABLE状态时,我们就称这个线程被唤醒,反之暂停。

按照导致上下文切换的因素划分,我们可以将上下文切换分为:
①自发性上下文切换
②非自发性上下文切换。
自发性上下文切换指线程由于自身因素导致的切出。从Java平台的角度来看,一个线程在其执行的过程中执行下列任意一个方法都会引起自发性上下文切换:
Thread.sleep(long millis)
Object.wait()/wait(long timeout)/wait(long timeout,int nanos)
Thread.yield()
Thread.join()/Thread.join(long timeout)
LockSupport.park()
此外,线程发起了I/O操作,或者等待其他线程持有的锁也会导致自发性上下文切换。
非自发性上下文切换由于线程调度器的原因被迫切出。导致非自发性上下文切换的常见因素包括被切换线程的时间片用完或者有一个比被切换线程优先级更高的线程需要运行。在Java平台的角度来看,Java虚拟机的垃圾回收动作也可能导致非自发性上下文切换。(因为垃圾回收器在执行垃圾回收的过程中可能需要暂停所有应用线程才能完成其工作。)

上下文切换的开销包括:
①直接开销
②间接开销
直接开销包括:
操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
线程调度器进行线程调度的开销。
间接开销包括:
处理器高速缓存重新加载的开销。
上下文切换也可能导致整个一级高速缓存中的内容被冲刷,即一级高速缓存中的内容会被写入下一级高速缓存(如二级高速缓存)或者主内存(RAM)中。

线程的活性故障

理想状况下我们希望线程一直处于RUNNABLE状态。但事实并非如此,导致一个线程可能处于非RUNNABLE状态的因素除了资源限制之外,还有程序自身的错误和缺陷。这些由于资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或者线程虽然处于RUNNABLE状态但是其要执行的任务却一直无法进展的现象就被称为线程活性故障。
常见的活性故障包括以下几种:
死锁(DeadLock):典型的例子,鹬蚌相争。一个线程X持有资源A的时候等待另外一个线程释放资源B,而另外一个线程Y持有资源B的时候却等待线程X释放资源A。死锁的外在表现是当事线程的生命周期状态用于处于非RUNNABLE状态而使其任务一直无法进展。
锁死(Lockout):锁死好比睡美人等待王子亲吻,但如果王子挂了(资源没了),那么睡美人将一直沉睡!
活锁(LiveLock):活锁好比小猫咬自己的尾巴,虽然一直在追,但总是咬不到。活锁的外在表现是线程可能处于RUNNABLE状态,但是线程所要执行的任务却丝毫没有进展,即线程可能一直在做无用功。
饥饿(Starvation):饥饿好比母鸟给雏鸟喂食的情形,弱小的雏鸟总是挨饿。饥饿就是线程因无法获得其所需的资源而使得任务执行无法进展的现象。

资源争用与调度

由于资源的稀缺性或者资源本身的特性(例如一个打印机一次只能打印一个文件),我们往往需要在多个线程间共享同一个资源。一次只能够一个线程占用的资源被称为排他性资源。常见的排他性资源包括处理器、数据库连接、文件等。在一个线程占用一个排他性资源进行访问而未释放其对资源所有权的时候,其他线程试图访问该资源的线程就被称为资源争用,简称争用。显然,争用是在并发环境下产生的一种线程。同时试图访问同一个已经被其他线程占用的资源的线程数量越多,争用的程度就越高;反之争用的程度就越低。高并发并非意外着高争用。
比如车辆通过收费站的情况:人工通道很挤,但ETC通道就算车流量大,因为不需要停车手动缴费,所以不用特地等其他车辆通过收费站。
多个线程共享同一个资源又会带来新的问题,即资源的调度问题。在多个线程申请同一个排他性资源的情况下,决定哪个线程会被授予该资源的独占权,即选择哪个申请者占用该资源的过程就是资源的调度。获得资源的独占权而未释放其独占权的线程就被称为该资源的持有线程。资源调度策略的一个常见特性就是它能否保证公平性。公平调度策略中的资源申请者总是按照先来后到的顺序来获得资源的独占权。非公平的调度策略允许插队现象,被唤醒的线程不一定就能够成功申请到资源。一般来说,非公平调度策略的吞吐率较高,即单位时间内它可以为更多的申请者调配资源。
非公平调度策略是我们多数情况下的首选调度资源。其优点是吞吐率较大;缺点是资源申请者申请资源所需的时间偏差可能较大,并可能导致饥饿现象。

本章知识结构图

image.png

第三章 线程同步机制

线程同步机制是一套用于协调线程间的数据访问及活动的机制,该机制用于保障线程安全以及实现这些线程的共同目标。

从广义上来说,Java平台提供的线程同步机制包括:锁、volatile关键字、final关键字、static关键字以及一些相关的API:如Object.wait()/Object.notify()等。本章只介绍相关关键字和API.用户协调线程间活动的相关API在下一章介绍。

锁概述

    线程安全问题的产生前提是**多个线程并发访问共享变量、共享资源**。将**多个线程对于共享资源的并发访问转化为串行访问**,即一个共享数据只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。锁(Lock)就是利用这种思路来保障线程安全的。

一个线程在访问共享数据前必须申请对应的锁,线程的这个动作被称为锁的获得。一个线程获得某个锁,我们就称该线程为相应锁的持有线程,一个锁一次只能被一个线程持有,访问结束后该线程必须释放相应的锁。**锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码就被称为临界区**。因此,**共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行**。
锁具有排他性,即一个锁一次只能被一个线程持有。这种锁会被称为排他锁或者互斥锁(Mutex)。还有一种读写锁,对排他锁进行了相对改进。

Java平台中的锁包括:
①内部锁:通过synchronized关键字实现&
②显式锁:通过java.concurrent.locks.Lock接口的实现实现。(如java.concurrent.locks.ReentrantLock类)

锁的作用
锁能够保护共享数据以实现线程安全,**其作用包括保障原子性、可见性、有序性**。
**锁通过互斥保障原子性。**一个锁一次只能被一个线程持有,在持有锁线程进行操作的时候,其它线程无法操作临界区内容,自然保证了原子性。
**可见性的保证是通过写线程冲刷处理器缓存和读线程刷新处理器缓存这两个动作实现的**。在Java中,**锁的获得隐含着刷新处理器缓存**这个动作。这意味着读线程能在执行临界区代码前将写线程对共享变量的更新同步到该线程执行处理器的高速缓存中;而**锁的释放隐含着冲刷处理器缓存这个动作**。因此,锁可以保障可见性。
   锁能够保障有序性。写线程在临界区所执行的一系列操作在读线程看来完全按照源代码执行一样。这并不意味着临界区内的内存操作不能够重排序。实际上也可能会重排序,但是只局限于临界区内。由于临界区内的操作具有原子性,写线程对共享变量的更新同时对读线程可见,因此这种重排序也不会对其他线程产生影响。

锁保证原子性、可见性、有序性的条件:
①线程在访问同一组共享变量的时候必须使用同一个锁。
②这些线程中的任意一个线程,即使其仅仅是读取这组共享数据而没有进行任何写操作,也需要在读取时持有相应的锁。

这两个条件必须都满足,否则不能保证。

锁的粒度:一个锁可以保护一个或多个共享数据。一个锁实例所保护的共享数据的数量大小就被称为锁的粒度。一个锁实例保护的共享数据的数量大,我们就称该锁的粒度粗,否则称该锁的粒度细。
可重用性:一个线程再其持有一个锁的时候能否再次或多次是申请该锁。如果一个线程持有一个锁的时候还能够继续成功申请该锁,则称该锁是重入的,否则就称其为非可重入的。

锁的开销及其可能导致的问题

锁的开销主要包括锁申请和释放锁产生的开销,以及锁可能导致的上下文切换的开销,这些开销主要是处理器事件。
还可能导致一些活性故障:
锁泄露:锁泄露是指一个线程获得某个锁之后,由于程序的错误、缺陷导致该锁一直无法被释放而导致其他线程一直无法获得该锁的对象。
死锁、锁死等,下几个章节介绍。

内部锁:synchronized关键字

synchronized有两种用法:
①修饰方法
②修饰代码块

具体操作有三种:
synchronized(this/.class/Object)
synchronized

synchronized修饰非静态方法、同步代码块的synchronized(this)和synchronized(非this对象)的用法锁的是对象;
synchronized修饰静态方法以及同步代码块的synchronized(类.class)用法锁的是类;

demo:
1.synchronized(this)

public class SynBean.png

public class ThreadA extends Thread{.png

public class ThreadB extends Thread {.png

BESEEBESBEBEGEE.png

185155258.png

可以看出,必须等另外一个线程执行完毕后,释放对象锁后,当前线程(线程B)获得对象锁之后,才能执行同步代码块。
对象锁不影响非同步代码块的执行,比如我们在methodB中去除synchronized(this),则线程B不会等A释放锁之后再执行非同步代码块。ex:


public void methodB( ).png

ApplicationsAndroid Studio.appContentsjrejdkContentsHc.png

②synchronized(obj)
其他操作都跟synchronized一样,只是锁住的不是this,而是我们new出来的final变量。


private final Object lock E new Object();.png

public void methodc().png

Wiisynchronized(obj).png

190306524.png

可以看到,synchronized(obj)与synchronized(this)作用一致,都是获取对象锁。

③synchronized(.class)与synchronized静态变量一样,都是获得类锁,一个类的所有对象共用一把锁!,当然跟synchronized(this/obj)一样,不作用于非同步方法。

public void methodE( ) {.png

private static void testSynClass() {.png

ApplicationsAndroid Studio.appContents.png

如上图,所有对象共用一把锁。
同时,类锁不影响非同步代码块,如果我们将methodF的synchronized(SynBean.class)去掉,则可以看到类锁不影响其他对象调用非静态代码块:


public void methodF(){.png

195749599 methodF...start.png

同时,类锁和对象锁互不影响!一个对象获取类锁的锁之后,不影响其对象锁内容同步执行:

public void methodF() d.png
plicationsAndroid Studio.appContentsjrejdkCc.png

显式锁:Lock接口

它提供了一些内部锁不具备的特性,并不是内部锁的替代品。
类java.util.concurrent.lcoks.ReentrantLock是Lock接口的默认实现类。
方法摘要如下:


d lock().png

一个Lock接口实例就是一个显式锁对象,Lock接口定义的lock方法和unlock方法分别用于申请和释放相应Lock实例表示的锁。
显式锁用法如下:


private final Lock lock-.; flil Lock 1 031.png

显式锁的使用包括以下几个方面:
①创建Lock接口的实例。默认可以使用ReentrantLock的实例作为显式锁的使用。从字面上可以看出这是一个可重入锁。
②在访问共享数据前申请相应的显式锁。直接调用Lock.lock()方法即可。
③在临界区访问共享数据,Lock.lock()与Lock.unlock()调用之间的代码区域为临界区。一般视上述的try代码块为临界区。共享数据的访问都仅放在该代码块中。
④共享数据访问结束后释放锁。释放锁可以直接通过Lock.unlock(),为了避免锁泄露,我们必须将这个操作放在finally块中执行。【锁泄露:行完临界区代码之后并没有释放引导该临界区的锁lock,这种现象(故障)就被称为锁泄漏(Lock Leak)。锁泄漏会导致其他线程无法获得其所需的锁,从而使得这些线程都无法完成其任务。】显式锁可能导致锁泄露,内部锁已经由编译器代为规避锁泄露问题。

可重入锁

也叫作递归锁,指的是同一线程外层函数获得锁之后,内层递归函数即使有获取该锁的代码,也不受影响。

synchronized:可重入锁;
java.util.concurrent.locks.ReentrantLock:可重入锁;
可重入就意味着:线程可以进入任何一个它已经拥有的锁所同步着的代码块。比如一个syn修饰的方法a,调用一个syn修饰的方法b,如果不是可重入锁,那么调用b的时候就会直接报错:IllegalMonitorStateException

不可重入锁demo:


显式锁的调用

ReentrantLock既支持非公平锁也支持公平锁。其中一个构造器签名为:
ReentrantLock(boolean fair)
该构造器使得我们在创建显式锁实例的时候可以指定相应的锁是否是公平锁,false非公平锁,true为公平锁。
公平锁的公平性增加了线程暂停和唤醒的可能性,即增加了上下文切换的代价。因此,公平锁适用于锁持有时间较长或者线程申请锁的平均间隔时间相对长的情景。公平锁的开销往往比使用非公平锁的开销大,因此显式锁默认使用的是非公平调度策略

显式锁与内部锁的比较

显式锁和内部锁各自使用场景不同,不能相互替代。

(1)灵活性
内部锁是基于代码块的锁,基本可以说没有灵活性,要么用,要么不用。
而显式锁是基于对象的锁,其使用可以充分发挥面向对象编程的灵活性。比如内部锁的申请与释放只能在一个方法中进行,而显式锁支持在一个方法内申请锁,在另外一个方法里释放锁。
(2)安全性
内部锁简单易用,不用导致锁泄露。
而显式锁可能导致锁泄露。
(3)调度性
在锁的调度方面,内部锁仅支持非公平锁;而显式锁既支持非公平锁,也支持公平锁
(4)可监控性
显式锁提供了一些接口方法可以用来对锁的相关信息进行监控,而内部锁不支持这种特性。比如
ReentrantLock中定义的方法isLocked()可用于检测相应锁是否被某个线程持有,getQueneLength()方法可用于检测相应锁的等待线程的数量。
(5)性能方面
Java 1.6/1.7对内部锁做了一些优化,包括锁消除、锁粗化、偏向锁和适配性锁。这些优化并没有在Java 1.6/1.7中并没有运用到显式锁上。

在Java 1.5中,在高争用的情况下,内部锁的性能急剧下降,而显式锁下降则小的多,到Java 1.6之后对内部锁进行优化后,差异就变得非常小了。

锁的选用

内部锁的优点是简单易用,显式锁的优点是功能强大;
一般来说,新开发的代码中可以选用显式锁。但需要注意显式锁的不正确使用可能导致锁泄露的问题,线程转储可能无法包含显式锁的相关信息,定位问题困难.
可以采用相对保守的策略:默认情况下选用内部锁,仅在需要显式锁所提供的特性的时候采选还用显式锁。比如,在多数线程持有一个锁的相对长或者线程申请锁的平均时间间隔相对长的情况下使用非公平锁不太恰当,这时候我们就可以考虑使用公平锁(比如构造ReentrantLock的时候传入true)

改进型锁:读写锁

锁的排他性使得多个线程无法以线程安全的方式在同一时刻对共享变量进行读取,不利于提高系统的并发性。
读写锁(Read/Write Lock)是一种改进型的排他锁,也被称为共享/排他锁。读写锁允许多个线程可以同时读取共享变量,但是一次只允许一个线程对共享变量进行更新。任何线程读取共享变量的时候,其他线程无法更新这些变量;一个线程更新共享变量的时候,其他线程都无法访问该变量。

HAL HEATER FA HE AVE OD Bhik ne stes s it.png

读写锁适用场景:
①只读操作比写操作(更新值)要频繁的多
②读线程持有锁的时间比较长
只有同时满足上面两个条件的时候,读写锁才是最适宜的选择。否则使用读写锁会得不偿失,开销比其他的要大。
ReentrantReadWriteLock所实现的读写锁是个可重入锁。ReentrantReadLock支持锁的降级,即一个线程持有读写锁的写锁的情况下可以继续获得相应的读锁。

锁的适用场景

锁是Java线程同步机制中功能最强大、使用范围最广,同时也是开销最大、可能导致问题最多的同步机制。多个线程共享同一组数据的时候,如果其中线程有涉及如下操作,可考虑用锁:

①check-then-act:一个线程读取共享数据并在此基础上决定下一个操作是什么。
②read-modify-write:一个线程读取共享数据并在此基础和是哪个更新该数据。不过某些像自增操作(例如:count++)这种简单的read-modify-write操作,可以用后续章节介绍的原子变量类来实现线程安全。
③共享数据间存在关联关系,为了保障操作的原子性可以考虑使用锁。例如,关于服务器的配置信息可能包括IP地址、端口号等。一个线程如果要对这些数据进行更新,则必须保证操作的原子性,即主机IP和端口号总是一起被更新的。

线程同步机制的底层助手:内存屏障

锁保证可见性的操作主要有两个:
①获得锁时:刷新处理器缓存
②释放锁时:冲刷处理器缓存
Java虚拟机底层实际上是借助内存屏障来实现上述两个动作的。内存屏障是对一类仅针对内存读、写操作指令的跨处理器架构的比较底层的抽象。是硬件之上,操作系统或JVM之下,对并发作出的最后一层支持。

硬件层的内存屏障分为两类:Load Barrier和Store Barrier即读屏障(也叫加载屏障)和写屏障(也叫存储屏障)。加载屏障作用是刷新处理器缓存;存储屏障是冲刷处理器缓存。

内存屏障有两个作用:1.阻止屏障两侧的指令重排序,如一道墙和屏障一样
2.强制把写缓冲区/高速缓存中的脏数据等写会主内存,让缓存中相应的数据失效。

Java有四种内存屏障,分别是LoadLoad、StoreStore、LoadStore、StoreLoad
LoadLoad屏障:
对于语句:Load1;LoadLoad;Load2;在Load2以及后续读操作指令执行之前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
对于语句:Store1;StoreStore;Store2;在Store2以及后续写操作指令执行之前,保证Store1要写的数据写入完毕;
LoadStore屏障:
对于语句:Load1;LoadStore;Store2;在Store2及后续写入操作执行前,保证Load1指令要读取的数据被读取完毕;
StoreLoad屏障:
对于语句:Store1;StoreLoad;Load2;在Load2及后续读操作执行前,保证Store1的写入执行完毕并对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这是个完毕屏障,兼备其他三种内存屏障的功能。

volatile关键字

volatile关键字表示被修饰变量的值容易变化(容易被其他线程更改),因而不稳定。
volatile变量的不稳定意味着对这种变量的读和写操作都必须从高速缓存或者主内存中读取,已读取变量的相对新值。因此,volatile变量不会被编译器分配到寄存器进行存储,对volatile变量的读写操作都是内存访问(访问高速内存相当于主内存)。
volatile关键字常被称为轻量级锁,其作用与锁的作用有相同的地方:保证可见性和有序性,不同的是,在原子性方面仅保证写volatile变量操作的原子性,但没有锁的排他性;volatile关键字的使用不会引起上下文切换,这是其称为轻量级锁的原因。

volatile的作用

volatile关键字的作用包括:
保障可见性、保障有序性和保障long/double操作的原子性。
因为long/double占8字节,64位,在32位虚拟机上对64位操作分为两步:高位操作、低位操作,可能出现一个线程读高32位,一个线程读低32位,导致竟态的出现。
随口一提:
①i++不是原子操作
分为3步:
<1> 先从内存中把i取出来放到寄存器中
<2>然后++,
<3>然后再把i复制到内存中,这需要至少3步
②i= 1;是原子操作
volatile仅仅能保障对其修饰变量写操作或者读操作的原子性,并不表示对volatile变量的赋值操作一定具有原子性。
比如volatile变量修饰的共享变量count1,执行操作:
count1 = count2 +1
如果count2是一个共享变量,那么可能在执行加1操作的时候,其他线程就已经更改了count2的值了,所以不是原子操作;而如果count2是一个局部变量,那就是原子操作了。
又比如:

image.png

一般而言,对volatile变量的赋值操作,其右边表达式中只要涉及共享变量(包括被复制的volatile变量本身),那么这个操作就不是原子操作。
DCL单例为什么加volatile:
参考博客
image.png

因为在注释5,new单例的时候,分为三部分:
①分配内存
②初始化对象
③设置对象指向刚分配的内存
如果不加的话,这三个指令可能指令重排,执行完①后直接执行③,导致内存中存储的是一个空值。
保障可见性、有序性原因
对于volatile变量的写操作,Java虚拟机会在该操作之前插入一个释放屏障,并在该操作之后插入一个存储屏障;释放屏障禁止volatile写操作与该操作之前的任何读。写操作进行重排序,保证了之前操作顺序执行,对于读线程是可见的,更新操作的感知顺序与相应源码一直,保证了有序性。
如果被修饰的变量是个数组,那么volatile关键字只能够对数据引用本身的操作(读取数组引用和更新数组引用)起作用,而无法对数组元祖的操作(读取、更新数组元素)其作用。
volatile关键字在可见性方面仅仅是保证读线程能够读取到共享变量的相对新值。对于引用型变量和数组变量,volatile关键字并不能保证能够读取到相应对象的字段、元素的相对新值。

volatile的开销

volatile变量的开销包括读变量和写变量两个方面。volatile变量的读、写操作都不会导致上下文切换,因为volatile的开销比锁要小。读取volatile的变量的成本会比在临界区读取变量要低,因为没有锁的申请与释放以及上下文切换的开销,但是其成本可能比读取普通变量要高一些。这是因为volatile变量的值每次都需要从高速缓存或者主内存中读取,而无法被暂存在寄存器中,从而无法发挥访问的高效性。

volatile的典型应用场景

volatile除了用于保障long/double型变量的读、写操作的原子性,还有如下几个典型应用场景:
①使用volatile作为状态标志。
②使用volatile保障可见性。
③使用volatile变量替代锁。volatile关键字并非锁的替代品,但是在一定条件下它比锁更合适(性能开销小,代码简单)。多个线程共享一组可变状态变量的时候,可以将这一组可变状态变量封装成一个对象,对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象应用赋值给相应的引用型变量来实现。
④使用volatile实现简易版读写锁。(待实操)
使用volatile必须具备以下两个条件:
(1)对变量的写操作不依赖于当前值(i++显然不符合此操作)
(2)该变量没有包含在其他变量的不变式中

CAS与原子变量

CAS(Compare and Swap)是对一种处理器指令(例如x86处理器中的cmpxchg指令)的称呼。
volatile虽然开销小一些,但是它无法保障"count++"这种自增操作的原子性。保障自增这种比较简单的操作的原子性我们有更好的选择---CAS。CAS能够将read-modify-write和check-and-act之类的操作转换为原子操作。

CAS原理
锁机制存在几个问题:
①在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题
②一个锁持有锁会导致其它所有需要此锁的线程挂起
③如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

锁分为悲观锁、乐观锁;(从其它维度上来看,也分为显式锁、内部锁)

悲观锁:
假设操作会发生并发冲突,一旦获得锁,其它需要锁的线程就挂起等待锁被释放后再申请。synchronized就是这种悲观锁。
乐观锁:
每次不加锁而是假设没有冲突去完成某项操作,如果因为冲突失败就重试,直到成功为止。
乐观锁用到的机制就是CAS.

CAS包含三个操作数:
①内存位置(V)
②预期原值(A)
③新值(B)
如果内存位置的值能和预期原值匹配的上,那么处理器就会自动将该位置值更新为新值。否则处理器不进行任何操作。CAS有效说明了:”我认为位置V应该包含值A;如果包含该值,则将B放到这个位置上;否则不会更改该位置的值,只告诉这个位置现在的值。”

CAS具体操作
通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新 值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值尚未同时更改,则 CAS 操作成功。
类似于 CAS 的指令允许算法执行读-修改-写操作,而无需害怕其他线程同时 修改变量,因为如果其他线程修改变量,那么 CAS 会检测它(并失败),算法可以对该操作重新计算

按照文章中的例子:
1.在内存地址V当中,存储着值为10的变量。
2.此时线程1想要把变量的值增加1.对于线程1来说,旧的预期值A=10,要修改的新值B=11
3.在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量率先更新成了11
4.线程1开始提交更新,首先进行A和地址V的实际值比较,发现A不等于V的实际值,提交失败
5.线程1重新获取内存地址V的值,并重新计算想要修改的新值。此时对于线程1来说,A=11,B=12,这个重新尝试的过程被称为自旋。
6.这一次没有其它线程修改V中的值,线程1进行Compare,发现A和地址V的值是匹配的。
7.接着线程1进行SWAP,把地址V的值替换为B,也就是12。
这样一次CAS就进行完成了。

CAS存在的问题
主要有三个:
①ABA问题。
如果一个值原来是A,变成了B,又变成了A,那CAS检查的时候发现它的值没有改变,但是实际上改变了。ABA问题解决的思路就是使用版本号,在变量前面加上一个版本号,每次变量更新的时候都把版本号加1,A-B-A就变成了1A-2B-3A。java1.5开始在atomic中提供了一个类AtomicStampedReference来解决ABA问题。
②自旋开销大。自旋CAS如果长时间不成功,会给CPU带来很大的开销。
3.只能保证一个共享变量的原子操作。
对多个共享变量操作时,循环CAS就无法保证操作的原子性,这时候可以用锁。也可以把多个变量合并成一个共享变量来操作。Java1.5开始提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里进行CAS操作。

简单分析一下原子类中的CAS实现:
以AtomicInteger为例,其自增方法:getAndIncrement中调用了Unsafe类的getAndAddInt方法


image.png

image.png

image.png

image.png

image.png

所以原子类AtomicInteger中的自增方法实际上调用的就是底层的compareAndSwapInt指令!也就是比较并交换!

第五章 线程间协作

单线程中,程序要执行的动作如果需要满足一定的条件,那么我们可以放在一个if语句中,满足条件就可以执行。
而在多线程编程中,可以有另外的选择:保护条件未满足可能是暂时的,稍后其他线程可能更新了保护条件涉及的共享变量而使其成立。因此在条件不满足时,可以将当前线程暂停,直到其所需的条件成立时再将其唤醒。
伪代码如下:


atomic{.png

这个操作必须得具有原子性。
一个线程因其执行目标所需的保护条件未满足而被暂停的过程就称为等待。一个线程更新了系统的状态,使得其他线程所需的保护条件得以满足的时候唤醒那些被暂停的线程的过程就被称为通知。

wait/notify 作用与用法

在Java中,Object.wait()/Object.wait(long)以及Object.notify()/Object.notifyAll()可用于等待和通知;
Object.wait()的作用是使执行线程被暂停(生命周期状态变更为WAITING),该方法可以用来实现等待;
Object.notify()的作用是唤醒一个被暂停的线程,调用该方法可实现通知。
相应的,Object.wait()的执行线程就被称为等待线程;Object.notify()的执行线程就被称为通知线程。 使用Java中的任何对象都能实现等待与通知。
使用wait()实现等待,模板如下:

synchronized(someObject).png

一个线程只有持有一个对象的内部锁的情况下才能够调用该对象的wait方法,因此Object.wait()调用总是放在相应对象所引导的临界区中。
设someObject为Java中任意一个类的实例,因执行someObject.wait()而被暂停的线程就被称为对象someObject上的等待线程。 同一个对象的同一个方法(someObject.wait() )可以被多个线程执行,因此一个对象上可能存在多个等待线程。
someObject.wait()会以原子操作的方式使其执行线程(当前线程)暂停并使该线程释放其持有的someObject对应的内部锁
someObject上的等待线程可以通过其他线程执行someObject.notify()来唤醒,可以唤醒someObject上的一个(任意的、随机的)等待线程,而不是全部的等待线程!
被唤醒的等待线程运行的时候,需要再次申请someObject对应的内部锁,被唤醒的线程再次持有someObject内部锁的情况下,继续执行someobject.wait()中剩余的指令。直到wait方法返回。
等待线程只在保护条件不成立的情况下才执行Object.wait()进行等待。执行wait前需要判断,其他线程更新了相关共享变量而导致该线程所需的保护条件又再次不成立。因此调用wait后我们需要再次判断此时保护条件是否成立。所以,对保护条件的判断以及Object.wait调用应该放在循环语句之中,以确保目标动作只有在保护条件成立的情况下才执行。
这就是为什么我们将wait放在循环体中。
Object.notify()
Object.notify()实现通知,模板代码如下:

synchronized(someObject) {.png

拥有上面这种 模板的方法被称为通知方法。包含两个要素:
①更新共享变量
②唤醒其他线程
一个线程只有在持有一个对象的内部锁的情况下,才能够执行该对象的notify方法,因此Object.notifyy调用总是放在相应对象内部锁的临界区中。
也即是由于Object.notify()要求执行线程必须持有该方法所属对象的内部锁,因此Object.wait()在暂停其执行线程的同时必须释放相应的内部锁;否则通知线程无法获得相应的内部锁,也就是无法执行相应对象对的notify方法来通知等待进程。
Object.notify()本身并不会将内部锁释放,只是在其调用所在的临界区代码执行结束后才会被释放。
因此,为了使等待线程在其被唤醒之后能够尽快再次获得对应的内部锁,我们要尽可能地将Object.notify()调用放在靠近临界区结束的地方。
调用Object.notify()所唤醒的线程仅是相应对象上的一个任意等待线程,这个线程并不一定是我们真正想要唤醒的那个线程。因此,这时候我们就需要Object.notifyAll(),它可以唤醒相应对象上所有等待线程。
等待线程和通知线程必须调用同一个对象的wait方法、notify方法来实现等待和通知,等待线程和通知线程是同步在同一个对象之上的两种线程。
Object.wait()的执行线程会一直处于WAITING状态,直到通知线程唤醒该线程并且保护条件成立。因此Object.wati()所实现的是无限等待。Object.wait()还有个版本,声明如下:

image.png

可以在wait中传入一个超时时间。如果被暂停的等待线程在这个时间内没有被其他线程唤醒,那么Java虚拟机会自动唤醒该线程。

Demo:

public class AlarmAgent {
    private final static AlarmAgent INSTANCE = new AlarmAgent();

    //是否连接上告警服务器
    private boolean connectedToServer = false;
    private final HeartbeatThread heartbeatThread = new HeartbeatThread();

    public static AlarmAgent getInstance() {
        return INSTANCE;
    }

    public void init() {
        connectToServer();
        heartbeatThread.setDaemon(true);
        heartbeatThread.start();
    }

    //新开线程连接服务器
    private void connectToServer() {
        new Thread() {
            @Override
            public void run() {
                doConnect();
            }
        }.start();
    }

    public void sendAlarm(String message) throws InterruptedException {
        synchronized (this) {
            while (!connectedToServer) {
                sout("报警代理还没连接上服务器,释放锁");
                wait();
                sout("wait....after");
            }
            sout("sendAlarm 被唤醒");
            //真正将告警消息上报到告警服务器
            doSendAlarm(message);
        }
    }

    public static void main(String[] args) {
        AlarmAgent agent = new AlarmAgent();

        try {
            //初始化连接服务器
            agent.init();
            agent.sendAlarm("一级警报!!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private void doSendAlarm(String message) {
        sout("代理发消息给服务器:" + message);
    }


    //连接
    private void doConnect() {
        sout("开始连接服务器...");
        try {
            Thread.sleep(3000);
            synchronized (this) {
                connectedToServer = true;
                //连接已经建立完毕,通知以唤醒告警发送线程
                sout("已经连上服务器~notify");
                notify();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    //心跳线程
    class HeartbeatThread extends Thread {
        @Override
        public void run() {
            try {
                //留一定的时间给网络连接线程与告警服务器建立连接
                Thread.sleep(1000);
                while (true) {
//                    if (checkConnection()) {
                    if (true) {
                        connectedToServer = true;
                    } else {
                        connectedToServer = false;
                        sout("告警代理已断开服务器");
                        connectToServer();
                    }
                    Thread.sleep(2000);
                }
            } catch (Exception e) {

            }
        }

        private boolean checkConnection() {
            boolean isConnected = true;
            final Random random = new Random();
            int rand = random.nextInt(1000);
            if (rand <= 500) {
                isConnected = false;
            }
            return isConnected;
        }
    }

    public void sout(String content) {
        System.out.println(getCurrentTime() + ".." + content);
    }

    private String getCurrentTime() {
        Date date = new Date();
        SimpleDateFormat sdf = new SimpleDateFormat("mm:ss:SSS");
        return sdf.format(date);
    }
}
wait/notify的开销及问题

①过早唤醒问题:
设一组等待/通知线程同步在对象someObject上。其中线程N1更新了一个共享变量导致保护条件成立,为了唤醒该保护条件的所有等待线程,N1执行了someObject.notifyAll(),W1,W2线程都被唤醒,但是W2的保护条件并没有成立,导致其被唤醒之后还需要继续等待。过早唤醒问题可以利用JDK 1.5引入的
java.util.concurrent.locks.Condition接口来解决。
②信号丢失问题:
如果等待线程在执行在执行Object.wait()前没有先判断保护条件是否已经成立,那么就可能出现:通知线程在等待线程进入临界区之前就已经更新了相关共享变量,使得保护条件成立并进行了通知,但是此时等待线程还没有被暂停,也就无所谓唤醒。就可能造成等待线程直接执行Object.wait而被暂停的时候,该线程由于么有其他线程进行通知而一直处于等待状态。
③欺骗性唤醒问题。等待线程有可能在没有其他任何线程执行Object.notify()/notifyAll的情况下被唤醒。这种现象被称为欺骗性唤醒。欺骗性唤醒也会导致过早唤醒。只要我们将对保护条件的判断和Object.wait调用放在一个循环语句之中,欺骗性唤醒就不会对我们造成实际的影响。
上下文切换问题。wait/notify可能导致较多的上下文切换。
首先,等待线程执行Object.wait()至少会导致该线程对相应对象内部锁的两次申请与释放。通知线程在执行Object.notify()/notifyAll时需要持有相应对象的内部锁,因此notify/notifyAl调用会导致一次锁的申请,而锁的申请与释放可能导致上下文切换。
其次,等待线程从被暂停到唤醒这个过程本身就会导致上下文切换。
再次,被唤醒的等待线程再继续运行时需要再次申请相应对象的内部锁,这又可能导致上下文切换。
最后,过早唤醒问题也会导致额外的上下文切换,因为被过早唤醒的线程仍然需要等待,需要再次经历被暂停和唤醒的过程。
下面有几个方法可以减少notify/notifyAll的上下文切换次数
①在保证程序正确性的前提下,使用Object.notify替代Object.notifyAll。notify调用不会导致过早唤醒,因此减少了相应的上下文切换开销。
②通知线程在执行完notify/notifyAll之后尽快释放相应的内部锁,这样可以避免被唤醒的线程在wait调用返回前再次申请相应内部锁,由于该锁尚未被通知线程释放而导致该线程被暂停。

Object.notify()/notifyAll()的选用

Object.notify()可能导致信号丢失的正确性问题,而Object.notifyAll()虽然效率不高(把不需要唤醒的等待线程也给唤醒了),但是其在正确性方面有保障。所以保守做法就是优先使用Object.notifyAll()以保障正确性,只有在有证据表明使用Object.notify()足够的情况下才使用Object.notify().需要满足以下两个条件才行:
①一次通知仅需要唤醒至多一个线程。
相应对象的等待集合中仅包含同质等待线程。所谓同质等待线程指这些线程使用同一个保护条件,并且这些线程在Object.wait()调用返回之后的处理逻辑一致。最为典型的同质等待线程是使用同一个Runnable接口实例创建的不同线程或者从同一个Thread子类的new出来的多个实例。
只有满足这两个条件,才能使用Object.notify()来替代notifyAll().

wait/notify 与Thread.join()

Thread.join()可以使当前线程等待目标线程结束之后才继续运行。join还有一个版本:

image.png

join(long)允许我们指定一个超时时间。如果目标线程没有在指定的时间内终止,那么当前线程也会继续运行。join(long)实际上就是使用了wait/notify来实现的。

sleep、yield、wait、join的区别

sleep、yield、wait、join的区别(阿里)
这里将关键信息摘录一下,防止博客删除。
1.Thread.sleep(long)
sleep:Thread类的方法,必须带一个时间参数。会让当前线程休眠进入阻塞状态并释放CPU(阿里面试题 Sleep释放CPU,wait 也会释放cpu,因为cpu资源太宝贵了,只有在线程running的时候,才会获取cpu片段),提供其他线程运行的机会且不考虑优先级,但如果有同步锁则sleep不会释放锁即其他线程无法获得同步锁 可通过调用interrupt()方法来唤醒休眠线程。
2.Thread.yield
让出CPU调度,Thread类的方法,类似sleep只是不能由用户指定暂停多长时间 ,并且yield()方法只能让同优先级的线程有执行的机会。 yield()只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。调用yield方法只是一个建议,告诉线程调度器我的工作已经做的差不多了,可以让别的相同优先级的线程使用CPU了,没有任何机制保证采纳。
3.Object.wait()
Object类的方法(notify()、notifyAll() 也是Object对象),必须放在循环体和同步代码块中(之所以放在循环体中是因为防止消息丢失),执行该方法的线程会释放锁,进入线程等待池中等待被再次唤醒(notify随机唤醒,notifyAll全部唤醒,线程结束自动唤醒)即放入锁池中竞争同步锁;调用wait方法必须要获取锁!!
4.Thread.join(long)
Thread.join详解
内部还是由Object.wait()实现,当前运行线程调用另一个线程的join方法,当前线程进入阻塞状态直到另一个线程运行结束等待该线程终止。 注意该方法也需要捕捉异常。
等待调用join方法的线程结束,再继续执行。如:t.join();//主要用于等待t线程运行结束,若无此句,main则会执行完毕,导致结果不可预测。

image.png

在实际开发中,**可以通过join方法来等待线程执行结束后再执行 **,有点类似future/callable的功能
比如:

public void joinDemo(){
   //....
   Thread t=new Thread(payService);
   t.start();
   //.... 
   //其他业务逻辑处理,不需要确定t线程是否执行完
   insertData();
   //后续的处理,需要依赖t线程的执行结果,可以在这里调用join方法等待t线程执行结束
   t.join();
}

Demo:


image.png

image.png

区别:
首先说明一点:只有runnable到running时才会占用cpu时间片,其他都会出让cpu时间片。线程的资源有不少,但我们必须额外注意CPU资源和锁资源这两类。

  1. Thread.sleep()和Object.wait()
    **sleep(long mills):让出CPU资源,但是不会释放锁资源。Thread的方法
    wait():让出CPU资源和锁资源。Object的方法
    wait用于锁机制,sleep不是,这就是为啥sleep不释放锁,wait释放锁的原因;sleep是线程的方法,跟锁没半毛钱关系,wait,notify,notifyall 都是Object对象的方法,是一起使用的,用于锁机制;
    **

java条件变量(Condition接口)

待学。

倒计时协调器(CountDownLatch)

待学。

栅栏(CyclicBarrier)

待学

信号量(Semaphore)

待学

你可能感兴趣的:(多线程编程指南核心篇笔记)