Java并发编程实战

月伴飞鱼 2024-08-13 16:39:16
学习书籍 > 编程书籍
支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者!

书籍介绍:https://book.douban.com/subject/10484692/

简介

并发简史

在早期的计算机中不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能访问计算机中的所有资源

在这种裸机环境中,不仅很难编写和运行程序,而且每次只能运行一个程序

  • 这对于昂贵并且稀有的计算机资源来说也是一种浪费

操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行

操作系统为各个独立执行的进程分配各种资源,包括内存,文件句柄以及安全证书等

如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括:

  • 套接字、信号处理器、共享内存、信号量以及文件等

之所以在计算机中加入操作系统来实现多个程序的同时执行,主要基于以下原因:

资源利用率:

  • 在某些情况下,程序必须等待某个外部操作执行完成,例如输入操作或输出操作等,而在等待时程序无法执行其他任何工作
  • 因此,如果在等待的同时可以运行另一个程序,那么无疑将提高资源的利用率

公平性:

  • 不同的用户和程序对于计算机上的资源有着同等的使用权
  • 一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使这些用户和程序能共享计算机资源
    • 而不是由一个程序从头运行到尾,然后再启动下一个程序

便利性:

  • 通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信
    • 这比只编写一个程序来计算所有任务更容易实现

这些促使进程出现的因素(资源利用率、公平性以及便利性等)同样也促使着线程的出现

线程允许在同一个进程中同时存在多个程序控制流

线程会共享进程范围内的资源,例如内存句柄和文件句柄

  • 但每个线程都有各自的程序计数器(Program Counter)、栈以及局部变量等

线程还提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性

  • 而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行

  • 线程也被称为轻量级进程,在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程

线程的优势

如果使用得当,线程可以有效地降低程序的开发和维护等成本,同时提升复杂应用程序的性能

线程能够将大部分的异步工作流转换成串行工作流,因此能更好地模拟人类的工作方式和交互方式

  • 此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读和维护

发挥多处理器的强大能力

建模的简单性

异步事件的简化处理

响应更灵敏的用户界面

线程带来的风险

Java对线程的支持其实是一把双刃剑

虽然Java提供了相应的语言和库:

  • 以及一种明确的跨平台内存模型(该内存模型实现了在Java中开发 编写一次,随处运行 的并发应用程序)

  • 这些工具简化了并发应用程序的开发,但同时也提高了对开发人员的技术要求,因为在更多的程序中会使用线程

当线程还是一项鲜为人知的技术时,并发性是一个高深的主题,但现在,主流开发人员都必须了解线程方面的内容

安全性问题

活跃性问题

性能问题

线程无处不在

即使在程序中没有显式地创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的

  • 这将给开发人员在设计和实现上带来沉重负担,因为开发线程安全的类比开发非线程安全的类要更加谨慎和细致

每个Java应用程序都会使用线程。当JVM启动时:

  • 它将为JVM的内部任务(例如,垃圾收集、终结操作等)创建后台线程,并创建一个主线程来运行main方法

AWT(Abstract Window Toolkit,抽象窗口工具库)和Swing的用户界面框架将创建线程来管理用户界面事件

Timer将创建线程来执行延迟任务。一些组件框架,例如Servlet和RMI,都会创建线程池并调用这些线程中的方法

线程安全性

什么是线程安全性

要编写线程安全的代码,其核心在于要对状态访问操作进行管理

  • 特别是对共享的(Shared)和可变的(Mutable)状态的访问

共享意味着变量可以由多个线程同时访问,而可变则意味着变量的值在其生命周期内可以发生变化

一个对象是否需要是线程安全的,取决于它是否被多个线程访问

这指的是在程序中访问对象的方式,而不是对象要实现的功能

要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问

如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果

Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式

  • 但同步这个术语还包括volatile类型的变量,显式锁(Explicit Lock)以及原子变量

原子性

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:

  • 竞态条件(Race Condition)

UnsafeCountingFactorizer中存在多个竞态条件,从而使结果变得不可靠

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件

  • 使用先检查后执行的一种常见情况就是延迟初始化

延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次

@NotThreadSafe
public class LazyInitRace{
 
   private ExpensiveObject instance=null;

    public ExpensiveObject getInstance(){
        if(instance==null)
        instance=new ExpensiveObject();
        return instance;
    }
}

LazyInitRace说明了这种延迟初始化情况

getInstance方法首先判断ExpensiveObject是否已经被初始化

  • 如果已经初始化则返回现有的实例,否则,它将创建一个新的实例,并返回一个引用
    • 从而在后来的调用中就无须再执行这段高开销的代码路径

复合操作,LazyInitRace包含一组需要以原子方式执行(或者说不可分割)的操作

要避免竞态条件问题,就必须在某个线程修改该变量时

  • 通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态
    • 而不是在修改状态的过程中

java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换

通过用AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问操作都是原子的

加锁机制

在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors

  • 如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量

内置锁,Java提供了一种内置的锁机制来支持原子性:

  • 同步代码块(Synchronized Block)

同步代码块包括两部分:

  • 一个作为锁的对象引用,一个作为由这个锁保护的代码块

以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块

  • 其中该同步代码块的锁就是方法调用所在的对象

静态的synchronized方法以Class对象作为锁

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)

线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁

  • 而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出

获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法

Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁

  • 当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁

如果B永远不释放锁,那么A也将永远地等下去

重入,当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞

  • 然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功

重入意味着获取锁的操作的粒度是线程,而不是调用

  • 重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程

当计数值为0时,这个锁就被认为是没有被任何线程持有

  • 当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1

如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减

  • 当计数值为0时,这个锁将被释放

用锁来保护状态

因为锁使得线程能够串行访问它所保护的代码路径,所以我们可以用锁来创建相关的协议

  • 以保证线程对共享状态的独占访问

只要始终如一地遵循这些协议,就能够确保状态的一致性

  • 操作共享状态的复合操作必须是原子的,以避免竞争条件

活跃性与性能

简单和性能互相制约,不要为了性能牺牲简单性

对象的共享

可见性

失效数据

非原子的64位操作:

  • volatile类型的64位数值变量(double和long)
  • java内存模型要求,变量的读取和写入操作必须是原子
  • 对于非volatile类型的long和double变量,JVM允许将64位的读写操作分解成两个32位操作

加锁与可见性

Volatile变量:

  • 只能保证可见性,原子性不能

发布与逸出

发布:

  • 一个对象能够在当前作用域之外的代码使用

逸出:

  • 某个对象不应该发布的时候被发布出去,不要在构造过程中使this引用逸出

线程封闭

当访问共享的可变数据时,通常需要使用同步,一种避免使用同步的方式就是不共享数据

  • 仅在单线程内访问数据,就不需要同步

Ad-hoc线程封闭

栈封闭

Threadlocal类

不变性

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

满足以下条件对象才是不可变的:

  • 对象创建以后其状态就不能修改

  • 对象所有域都是final类型

  • 对象是正确创建的(在对象创建期间,this引用没有逸出)

安全发布

在静态初始化函数中初始化一个对象引用

将对象的引用保存到volatile类型的域或者AtomicReferance对象中

将对象的引用保存到某个正确构造对象的final类型域中

将对象的引用保存到一个由锁保护的域中

对象的组合

设计线程安全的类

设计线程安全类的三个基本要素:

  • 找出构成对象状态的所有变量

  • 找出约束状态变量的不变性条件

  • 建立对象状态的并发访问管理策略

实例封闭

当一个对象被封装到另一个对象中,能够访问到被封装对象的所有代码路径都是已知的

  • 通过将封闭机制和合适的加锁策略结合,可以确保以线程安全的方式来使用非线程安全的对象
public class PersonSet{  
       private final Set<Person> mySet = new HashSet<Person>();  

       public sychronized void addPersion(Person p) {  
              mySet.add(p)  
         }  

       public sychronized boolean containsPerson(Person p) {  
              return mySet.contains(p);  
         }  
    }

虽然HashSet 并非线程安全的,但是mySet是私有的不会逸出

  • 唯一能访问mySet的代码是addPerson()containsPerson()

在执行上他们都要获的PersonSet上的锁

PersonSet的状态完全又它的内置锁保护

所以PersonSet是一个线程安全的类

Java平台的类库有很多实例封闭的例子

  • 比如一些基本的容器并非线程安全的,如ArrayList,HashMap

类库提供的包装器方法,Collections.synchronizedList(list)、Collections.synchronizedMap(m)

  • 只要这些包装器对象拥有对被包装容器对象的唯一引用(即把容器对象封装在包装器中),非线程安全的类就可在多线程中使用

线程安全性的委托

class Counter {

    private AtomicInteger count = new AtomicInteger(0);

    private int inc(){
        return count.incrementAndGet();
    }
}

对于Counter来说,由于Counter只有一个域就是AtomicInteger,而AtomicInteger又是线程安全的

  • 所以很容易知道Counter是线程安全的

Counter把它的线程安全性交给了AtomicInteger来决定,也就说委托给了AtomicInteger来保证

支付宝打赏 微信打赏

如果文章对你有帮助,欢迎点击上方按钮打赏作者!