java并发编程

一、线程

为了提高资源利用率,保证不同的用户和程序对于计算机上的资源有着同等的使用权,使任务更容易实现,计算机加入了操作系统来实现多个程序的同时执行。在指令的设计和执行上遵循串行编程模型,即根据机器语言的语义以串行方式执行命令。大多操作系统以线程为基本的调度单位。线程会带来的问题如下:

1.安全性

安全性的含义是“永远不发生糟糕的事情”。
在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行性是很难分析的。
java通过同步机制,来保证对共享变量的访问操作不会破坏线程的安全性。

2.活跃性

活跃性关注的是“某件正确的事情最终会发生”。
如果线程A在等待线程B释放其持有的资源,而线程B永远不释放该资源,那么A就会永久地等待下去。这样,线程A无法继续执行下去,就会发生活跃性问题。
几种活跃性问题:

  • 死锁
  • 饥饿
  • 活锁

3.性能

性能问题包括:

  • 服务时间过长
  • 响应不灵敏
  • 吞吐率过低
  • 资源消耗过高
  • 可伸缩性较低

每个java程序都会使用线程。当JVM启动时,内部任务会创建后台线程,并创建一个主线程来运行main方法。

二、线程安全性

如何通过同步来避免多个线程在同一时刻访问相同的数据

在构建稳健的并发程序时,必须正确地使用线程和锁。
要编程线程安全的代码,其核心在于要对状态访问操作进行管理,主要对共享的(Shared)和可变的(Mutable)状态的访问。

对象的状态:存储在状态变量中的数据。
共享:变量可以由多个线程同时访问。
可变:变量的值在其生命周期内可以发生变化。

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。
要使得对象是线程安全的,需要采用同步机制来协同对对象可变的状态的访问。

java的主要同步机制是关键字synchronized

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

1.定义

线程安全性:核心概念是正确性,即某个类的行为与其规范完全一致。

线程安全的:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

2.原子性

竞态条件:由于不恰当的执行时序而出现不正确的结果的情况。
当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。
最常见的竞态条件类型就是“先检查后执行”(Check-Then-Act):基于一种可能失效的观察结果来做出判断或者执行某个计算。使用“先检查后执行”的一种常见情况是延迟初始化

假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。

复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

3.加锁机制

  • 内置锁:同步代码块

    线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。
    获得内置锁的唯一途径是进入由这个锁保护的同步代码块或方法。

  • 重入
    实现方法:为每个锁关联一个获取计数值和一个所有者线程。
    当计数值为0时,这个锁就被认为是没有被任何线程持有。
    当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。

4.锁的状态
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁来保护的。

对象的内置锁与其状态之间没有内在的关联。

只有被多个线程同时访问的可变数据才需要通过锁来保护。

将每个方法都作为同步方法还可能导致活跃性问题或性能问题。

三、对象的共享

如何共享和发布对象,从而使它们能够安全地由多个线程同时访问

同步的另一个方面:内存可见性(Memory Visibility)

1.在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。

volatile变量,用来确保将变量的更新操作通知到其他线程。在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞。
仅当volatile变量能简化代码的实现以及对同步策略的验证是,才使用。

2.发布(Publish):使对象能够在当前作用域之外的代码中使用。
逸出(Escape):当某个不应该发布的对象被发布时。

发布对象的方法:

  • 将对象的引用保存到一个公有的静态变量中
  • 发布一个内部的类实例

逸出的一个情况:this引用在构造函数中逸出。

3.线程封闭:不共享数据。
应用:JDBC的Connection对象。
维持线程封闭性:局部变量和ThreadLocal类

  • Ad-hoc线程封闭
    维护线程封闭性的职责完全由程序实现来承担
  • 栈封闭
    只能通过局部变量才能访问对象
  • ThreadLocal类
    ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。

4.不变性
另一种同步的方法:使用不可变对象
如果某个对象在被创建后其状态就不能被修改,那么这个对象就被称为不可变对象。

不可变性并不等于将对象中所有的域都声明为final类型,在final类型的域中可以保存对可变对象的引用。

对象是不可变的三大条件:

  • 对象创建后其状态就不能修改
  • 对象的所有域都是final类型
  • 对象是正确创建的

final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。

5.安全发布
不可变形的所有需求:

  • 状态不可修改
  • 所有域都是fInal类型
  • 正确的构造过程

一个正确的构造的对象可以通过以下方式来安全发布:

  • 在静态初始化函数中初始化一个对象引用
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中
  • 将对象的引用保存到某个正确构造对象的final类型域中
  • 将对象的引用保存到一个由锁保护的域中

    发布一个静态构造的对象的方法:使用静态的初始化器

在并发程序中使用和共享对象时可以使用的策略:

  • 线程封闭
    线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改
  • 只读共享
    在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。
    共享的只读对象包括不可变对象和事实不可变对象
  • 线程安全共享
    线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公用接口来进行访问而不需要进一步的同步。
  • 保护对象
    被保护的对象只能通过持有特定的锁来访问。保护对象包含封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

线程安全:

1.无状态对象一定是线程安全的。
无状态的:既不包含任何域,也不包含任何对其他类中域的引用。
大多数Servlet都是无状态的。

2.不可变对象一定是线程安全的。


四、对象的组合

如何设计线程安全的类

  1. 找出构成对象状态的所有变量
  2. 找出约束状态变量的不变性条件
  3. 建立对象状态的并发访问管理策略

构建线程安全类的方式:实例封闭

五、基础构建模块

1.同步容器类:Vector, Hashtable。

这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公用方法都进行同步,使得每次只有一个线程能访问容器的状态。

同步容器类都是线程安全的。容器上常见的复合操作包括:

  • 迭代

    反复访问元素,直到遍历完容器中所有元素

  • 跳转

    根据指定顺序找到当前元素的下一个元素

  • 条件运算

2.并发容器

  • ConcurrentHashMap

    基于散列的Map,通过分段锁机制实现更大程度的共享。

  • CopyonWriteArrayList

    用于代替同步List,在迭代期间不需要对容器进行加锁或复制。

    “写入时复制(Copy-On-Write)”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致。

    仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。

3.阻塞队列(Blocking Queue)

阻塞队列与普通队列的区别:

  1. 当队列是的,从队列中获取元素的操作将会被阻塞
  2. 当队列是的,往队列中添加元素的操作会被阻塞

也就是说,试图从空的阻塞队列里面获取元素的线程将会阻塞,直到其他的线程往空的队列插入新的元素。
试图往满的阻塞队列里面添加元素的线程将会阻塞,直到其他的线程使队列重新变得空闲起来。

(1)提供的方法

  • pull方法

    如果队列已经满了,那么put方法将阻塞直到有空间可用。

  • take方法

    如果队列为空,那么take方法将会阻塞直到有元素可用。

(2)支持生产者-消费者这种设计模式。

  • 当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。
  • 生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需将数据放入队列即可。
  • 消费者不需要知道生产者是谁,或者工作来自何处。

常见的实例:线程池与工作队列的组合。

4.阻塞方法与中断方法

Thread的interrupt方法,用于中断线程或者查询线程是否已经被中断。

最常使用中断的情况:取消某个操作。

传递InterruptedException
恢复中断

5.同步工具类

(1)闭锁

  • 确保某个计算在其需要的所有资源都被初始化之后才继续执行
  • 确保某个服务在其依赖的所有其他服务都已经启动之后
  • 等待直到某个操作的所有参与者都就绪再继续执行

(2) FutureTask

FutureTask实现了Future语义,表示一种抽象的可生成结果的计算。通过Callable来实现的,相当于一种可生成结果的Runnable。

三种状态:

  • 等待运行(Waiting to run)
  • 正在运行(Running)
  • 运行完成(Completed)

执行完成有三种原因:正常结束,因取消而结束,因异常而结束

当FutureTask进入完成状态后,会永远停止在这个状态上

(3)信号量(Semaphore)

用来控制同时访问某个特定资源的操作数量。

管理着一组虚拟的许可(permit)

acquire方法:获得许可
release方法:释放许可

(4)栅栏(Barrier)

能阻塞一组线程直到某个事件发生。

栅栏:用于等待线程,所有线程必须同时到达栅栏位置,才能继续执行
闭锁:用于等待事件

Exchanger,是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据。

6.如何开发一个高效且可伸缩的缓存

缓存的作用:避免相同的数据被计算多次

(1)使用HashMap来保存之前计算的结果

但HashMap不是线程安全的,为了保证线程安全,需要对方法A进行同步,这样,每次只有一个线程能够执行方法A,性能会下降。

(2)使用ConcurrentHashMap

线程安全,但当两个线程同时调用方法A时,可能会进行重复的计算。

(3)使用FutureTask

先检查计算是否已经开始,
还没开始,创建一个FutureTask,
已经开始,等待现有计算的结果。

六、任务执行

任务执行(Task Execution)的过程:找出清晰的任务边界, 明确任务执行策略。

1.通过线程来执行任务的策略

  • 在单个线程中串行地执行任务

    缺陷:响应性和吞吐量糟糕

  • 为每个任务分配一个线程

    缺陷:资源管理的复杂性

  • 在线程池中执行任务

    线程池(ThreadPool):管理一组同构工作线程的资源池。
    工作队列(Work Queue)
    工作者线程(Worker Thread)

2.Executor框架

任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。

Executor接口

public interface Executor{
    void execute(Runnable command);
}
  • 执行任务的生命周期:创建、提交、开始、完成

实现Executor接口的ExecutorService

  • 生命周期有 运行、关闭和已终止,通过shutdown方法从运行状态变为关闭状态。

携带结果的任务
Callable

Future

3.ScheduledThreadPoolExecutor

代替Timer类,负责管理延迟任务及周期任务。

七、取消与关闭

1、实现取消:

  • 中断
  • Future

java没有提供任何机制来安全地终止线程,但提供了中断(Interruption)机制。

中断操作:不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。

当检查到中断请求时,任务并不需要放弃所有的操作,而是完成当前任务后抛出InterruptedException,表示已收到中断请求。

2、关闭:

关闭ExecutorService:

  • 使用shutdown正常关闭
  • 使用shutdownNow强行关闭

关闭生产者- 消费者模式:

  • 使用“毒丸(Poison Pill)”对象

    毒丸对象是一个放在队列上的对象,在FIFO队列里,在提交毒丸对象之前的操作都会被处理。只有在生产者和消费者的数量都已知的情况下,才可以使用“毒丸”对象。

关闭JVM:

  • 关闭钩子(shutdown hook)

3、线程非正常终止

主要原因:RuntimeException

处理方法:

  • 使用try-catch代码块捕获

  • 调用Thread API里面的UncaughtExceptionHandler

public interface UncaughtExceptonHandler{
    void uncaughtException(Thread t, Throwable e);
}

八、线程池

1、线程饥饿死锁(Thread Starvation Deadlock)

2、线程池的最佳大小

int N_CPUS = Runtime.getRuntime().availableProcessors();

N_CPUS = numbers of CPUs
U_CPUS = target CPU utilization, 0<= U_CPUS <=1
W/C = ratio of wait time to compute time

所以线程池的最佳大小N

N = N_CPUS * U_CPUS * (1 + W/C)

3、ThreadPoolExecutor

允许提供一个BlockingQueue来保存等待执行的任务。

九、活跃性

1、死锁(DeadLock)

“哲学家进餐”问题产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。

死锁类型:

  • 锁顺序死锁
  • 动态锁顺序死锁
  • 在协作对象之间发生死锁

开放调用(Open Call):调用某个方法时不需要持有锁。

2、饥饿(Starvation)

定义:线程由于无法访问它所需要的资源而不能继续执行

解决办法:避免改变线程的优先级

3、活锁(Livelock)

定义:线程不断执行相同的操作,而且总会失败。

解决办法:在重试机制中引入随机性。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值