Java并发编程实战(上)
2024-12-28 11:03:23 0 举报
AI智能生成
书:Java并发编程实战
作者其他创作
大纲/内容
线程简介
线程的优势
1.发挥多处理器的能力
单线程程序只能同时使用一个CPU, 多线程可以在多个CPU上执行,提高系统吞吐
2.建模的简单性
开发人员只需要关注业务层的开发, 不需要关注计算机资源调度等问题
3.异步事件的简化处理
网络调用中单线程容易阻塞, 多线程提高系统吞吐
4.相应更灵活的用户界面
异步减少等待长时间运营任务的等待时间, 提高用户体验
线程带来的风险
1.安全性问题
多线程之间交替操作不可预测,容易产生不符合预期的结果
2.活跃性问题
无意中造成的无限循环无法避免, 如A线程等待B线程, B线程一直阻塞
3.性能问题
多线程会带来运行时的开销,如上下文切换
线程安全性
什么是线程安全性
当多个线程访问同一个类时,这个类始终能表现出正确的行为, 那么就称这个类是线程安全的
线程安全的本质是内存安全, 只要同一个内存空间不会被多个线程引用并修改, 那么是线程安全的
无状态的对象一定是线程安全的, 如:servlet
原子性
竞态条件
常见的竞态条件类型就是"Check-Then-Act",即通过一个可能失效的观察结果来决定下一步的动作, 但这种观察在并发场景很可能无效
延迟初始化
"Read-Modify-Write"将对象的初始化操作推迟到实际被使用时才进行, 并发场景中, 创建的对象有概率丢失部分信息
复合操作
上面两种形式都各需要以原子方式执行的操作
要避免静态条件的问题, 就必须在某个线程修改变量时, 防止其他线程使用这个变量
尽可能使用现有的线程安全对象(如:AcomicLong)来管理类的状态
加锁机制
内置锁
Java提供一种内置锁机制支持原子性: 同步代码块(Synchronized Block)
只有一个线程可以持有该锁, 虽然可以保证原子性,但是这种方法存在性能问题
重入
由于内置锁是可重入的,意味着获取锁操作的粒度是"线程", 而不是"调用"
如果内置锁不可重入,那么这段代码则会出现死锁
用锁来保护状态
每一个共享的变量和可变的变量都应该只由一个锁来保护
如果不加区分滥用syncchronized,可能导致程序出现过多同步,如Vector上的复合操作
虽然vector内的方法均为原子操作, 但是合并为复合操作后, 该代码片段仍然不是原子操作
活跃性与性能
在简单性与性能之间存在象湖制约因素, 当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性
当执行时间较长的计算或者可能无法快速完成的操作时, 一定不要持有锁
CacheFacotorizer实现了在简单性与并发性之间的平衡, 保证了线程安全, 也不会过多影响并发,同步代码块的路径足够短
对象的共享
可见性
失效数据
通常, 我们无法确保执行读操作的线程能适时地看到其他线程写入的值
这段代码看起来会输出42, 但事实上很可能输出0, 或根本无法终止
get和set都是在没有同步的情况下访问value, 可能会出现失效值的问题
通过对get和set等方法进行同步,可以使Mutable成为一个线程安全的类, 仅对set方法进行同步是不够的,调用get的线程仍然能看到失效值
非原子的64位操作
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位分解为两个32位操作
当读取一个非volatile类型的long变量时,如果读写不在同一线程,很可能会读取到某个值的高32位和另一个值的低32位
多线程场景, 使用共享且可变的long或double等类型的变量是不安全的
加锁与可见性
当线程B执行同步代码块时, 可以看到线程A之前在同步代码块中的所有操作
加锁的含义不仅仅局限于互斥行为,还包含了内存可见性
Volatile变量
当把变量声明为volatile类型后, 编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序
访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量比synchronized关键字更轻量
这样可以保证asleep的可见性
仅当满足以下所有条件,才应该使用volatile变量
对变量的写入操作不依赖变量的当前值,或者能保证只有单个线程更新该值
该变量不会与其他状态变量一起纳入不变形条件中
在访问变量时不需要加锁
在当前大多数处理器架构中,读取volatile变量的开销只比非volatile变量开销略高一点
发布与逸出
发布指的是,使对象能够在当前作用域之外的代码中使用
逸出指的是, 一个不应该发布的对象被发布
按照这种方式发布this, this已经引用逸出
使用工厂方法来防止this引用在构建过程中的逸出
线程封闭
Ad-hoc线程封闭
维护线程封闭性的职责完全由程序实现承担, 如volatile, 这种封锁技术的脆弱性, 在程序中尽量少用
栈封闭
使用基本类型的局部变量, 或能保证不会逸出的对象引用
ThreadLocal类
ThreadLocal的get和set方法,视为每个使用该变量的线程都存有一份独立的副本, 如ThreadLocal<T> 视为包含Map<Thread,T>对象
如事务的上下文, 通过将上下文保存在静态的ThreadLocal对象中, 避免每个方法都要执行上下文信息
不变性
Final域
final域能够确保初始化过程的安全性,从而可以不受限制地访问不可变对象, 并在共享这个对象市无需同步
使用Volatile类型来发布不可变对象
使用指向不可变容器对象的volatile类型引用以缓存最新的结果
安全发布
不正确的发布: 正确的对象被破坏
由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为未被正确发布。
有可能出现某个线程在第一次读取域时得到失效值,再次读取这个域会得到一个更新值,这也是会抛出AssertionError的原因
有可能出现某个线程在第一次读取域时得到失效值,再次读取这个域会得到一个更新值,这也是会抛出AssertionError的原因
即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步
不可变对象与初始化安全性
任何线程都可以在不需要额外同步的情况下安全访问不可变对象,即使在发布这些对象没有使用同步
安全发布的常用模型
一个正确构造的对象可以通过下面方式安全发布
在静态初始化函数中初始化一个对象引用
将对象的引用保存到volatile类型的域或者AtomicReferance对象中
将对象的引用保存到某个正确构造对象的final类型域中
将对象的引用保存到一个由锁保护的域中
通常, 要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器
public static Holder holder = new Holder(42);
public static Holder holder = new Holder(42);
事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不会在改变,那么把这种对象称为"事实不可变对象"
必须通过安全方式发布
可变对象
必须通过安全方式发布,而且必须是线程安全的或者某个锁保护起来的
安全地共享对象
线程封闭
线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改
只读共享
在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问, 但不能修改它,共享的只读对象包括不可变对象和事实不可变对象
线程安全共享
线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步同步
保护对象
被保护的对象只能通过持有特定的锁来访问, 保护对象包括封装在其他线程安全对象中的对象, 以及已发布的并且由某个特定锁保护的对象
对象的组合
设计线程安全的类
收集同步需求
如何不了解对象的不变性条件与后验条件,那么不能保证线程安全性
依赖状态的操作
单线程程序中, 如果某个操作无法满足先验调整, 那么只能失败
但在并发程序中,先验条件可能由于某些其他线程执行的操作而变成真, 在并发程序中要一直等到先验条件为真再执行操作
例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于不为空的状态
状态的所有权
对象封装它拥有的状态, 对象需要维护状态的安全性
ServletContext为Servlet中的Map容器, 使用这个Map中的对象,可能需要同步, Servlet则需要维护这个Map能被安全共享
实例封闭
实例封装
将数据封装在对象内部, 可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁
mySet并非线程安全,但可通过封装出入口, 达到线程安全的效果
Java监视器模式
是一种编码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态
线程安全性委托
多个线程安全类组合而成的类,共同使用时,不一定线程安全
在现有的线程安全类中添加功能
客户端加锁机制
上面这个方式并不能实现线程安全,putIfAbsent相对于List的其他操作来说并不是原子的
因此就无法确保当putIfAbsent执行时另一个线程不会修改链表
因此就无法确保当putIfAbsent执行时另一个线程不会修改链表
这样实现线程安全
通过添加原子操作来拓展类是脆弱的,因为它将类的加锁代码分布到多个类中, 然而,客户端加锁更加脆弱, 因为它将类C的加锁代码放在与C无关的其他类当中
组合
ImprovedList通过自身的内置锁增加了一层额外的加锁,它并不关心底层的List是否线程安全,ImprovedList也会提供一致的加锁机制来实现线程安全性
基础构建模块
同步容器类
同步容器类的问题
这些方法看似没有问题, 但从调用者角度来看, 调用方法并不是原子的
交替调用getLast和deleteLast时将抛出ArrayIndexOutOfBoundsException
加锁复合操作
迭代器与ConcurrentModificationException
为了降低并发修改操作的检测代码对程序性能的影响, 如果迭代期间计数器被修改,那么hasNext将抛出ConcurrentModificationException
隐藏迭代器
容器的hashCode和equal等方法也会间接地执行迭代操作,都可能抛出ConcurrentModificationException
并发容器
ConcurrentHashMap
通过并发容器去替换同步容器,可以极大的提高伸缩性并降低风险
它提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程加锁
它返回的迭代器具有弱一致性,而并非"及时失败", 弱一致性的迭代器可以容忍并发修改
如size和isEmpty的语义被略微弱化,因为并发场景下计算方法的用处比较小, 所以返回的只是一个预估值
额外的原子Map操作
若需要实现复合操作, "若没有则添加", 如putIfAbsent,replace等,则需要考虑用ConcurrentHashMap
CopyOnWriteArrayList
在迭代期间不需要对容器进行加锁或复制, 写入时创建一个副本, 然后替换
Copy-On-Write容器的线程安全性在于,只要正确地发布一个事实可不变的对象, 那么在访问该对象时就不再需要进一步同步
仅当迭代操作远远多于修改操作时,才应该使用"写入时复制"容器
阻塞队列和生产者-消费者模式
串行线程封闭
对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付到消费者
双端与工作密取
Deque是典型的双端队列,实现了在队头和队尾的插入和移除
工作密取设计中,每个消费者都有各自的双端队列,一个消费者完成了自己的队列任务, 从其他消费者的尾端"秘密获取",降低了队列上的竞争程度
阻塞方法和中断方法
BlockingQueue的put和take等方法会抛出受检查异常(Check Exception) Interrupted Exception, 这类方法即为阻塞方法
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断
同步工具类
闭锁
在闭锁到达结束状态之前, 没有任何线程能通过, 当到达状态为结束时, 门会打开允许所有线程通过
CountDownLatch是一种灵活的闭锁实现
FuntureTask
FutureTask也是闭锁的一种,当进入完成状态后,它会永远停止在这个状态
Future.get的行为取决于任务的状态,如果任务已经完成,那么get会立即返回结果,或者阻塞
信号量
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量,还可以实现某种资源池,或者对容器施加边界
Semaphore中管理着一组虚拟的permit,具有互斥,不可重入的语义
Semaphore可以用于实现资源池, 或有界阻塞容器
栅栏
栅栏类似于闭锁,能阻塞一组线程直到某个事件发生, 区别在于所有线程必须同时到达栅栏位置,才能继续执行
闭锁等待事件, 栅栏等待其他线程
构建高效且可伸缩的结果缓存
使用HashMap和同步机制来初始化缓存
Memoizer1对整个compute方法进行同步, 虽保证了线程安全, 但并发糟糕
用ConcurrentHashMap代替HashMap
Memoizer2比Memoizer1有着更好的并发行为, 但当2个线程同时调用compute时同时计算多次, 造成性能浪费
基于FutureTask的Memoizing封装器
相比Memoizer2,Memoizer3表现出非常好的并发性, 但依然有一个缺陷,compute方法的if代码仍然是非原子的
Memoizer的最终实现
任务执行
在线程中执行任务
串行执行任务
SingleThreadWebServer很简单,但是每次只能处理一个请求
显式为任务创建线程
在正常负载的情况下, "为每个任分配一个线程"的方法能提升执行的性能
无限创建线程的不足
线程声明周期的开销非常高, 创建线程并不是没有代价的, 每个请求创建一个线程将消耗大量计算资源
资源消耗, 可运行的线程数大于CPU数量,大量线程的竞争将会产生性能损耗
稳定性,容易造成OOM
Executor框架
基于Executor的Web服务器
在TaskExecutionWebServer中,通过使用Executor将处理任务的提交与任务的实际执行解耦开来
执行策略
在执行策略中定义任务执行的"What,Where,When,How"
What-在什么线程中执行任务
What-任务按照什么顺序执行(FiFO,LIFO,优先级)
How Many-在队列中有多少个任务等待执行
How Many-有多少个任务并发执行
Which-如果系统需要拒绝任务,选择哪一个任务
How-如何通知应用程序有任务被拒绝
What-在执行一个任务之前或之后,应该进行哪些动作
线程池
从"为每个任务分配一个线程"策略变成基于线程池的策略,Web服务器不会再在高负载情况下失败
由于服务器不会创建数千个线程来争夺有限的CPU和内存资源,因此服务器的性能将平缓降低
Executor的生命周期
Executor的实现通常会创建线程来执行任务, 但JVM只有在所有(非守护)线程全部终止才会退出, 因此如果无法正确关闭Executor,那么JVM将无法结束
ExecutorService的生命周期有3种状态: 运行,关闭和已终止
延迟任务与周期任务
Timer类负责管理延迟任务以及周期任务
Timer在执行所有定时任务时只会创建一个线程, 如果某个任务的执行时间过长,那么将破坏其他TimeTask的定时准确性
Timer在抛出一个未知异常时, Timer将不会恢复线程执行
找出可利用的并行性
示例:串行的页面渲染器
在SingleThreadRenderer中, 整个过程串行执行,图像下载过程大部分时间都是在等待I/O操作执行完成, 方法并没有充分利用CPU
示例:使用Future实现页面渲染器
在FutureRunderer中, 创建一个Callable来下载所有图像,更能提高性能
在异构任务并行化中存在的局限
尝试并行执行2个不同的任务,下载图像与渲染页面, 但对异构任务进行并行化来获得重大性能提升是很困难的
当请求增多时,如何确保他们能不妨碍其他请求的工作,并不是容易的事情
FuntureRenderer使用2个任务, 如果渲染文本的速度远大于下载图片,那么程序最终性能与串行执行时性能差距不大, 而代码复杂度更高
示例: 使用CompletionService实现页面渲染器
通过CompletionSerivce,使页面元素在下载完成后立即显示出来
0 条评论
下一页