您的位置:首页 > 编程语言 > Java开发

Java 并发编程的艺术

2017-12-27 00:00 260 查看
一、 减少上下文切换的方法
1. 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
2. CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
3. 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
4. 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换;

二、避免死锁的几个常见方法
1. 避免一个线程同时获取多个锁。
2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
3. 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
4. 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。

三、资源限制的挑战

资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。
例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接数和socket连接数等;

在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成了。

对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。

根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞,等待数据库连接;

第2章 Java并发机制的底层实现原理

Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和CPU的指令。

synchronized和volatile: volatile是轻量级的synchronized,它在多处理器开发中了共享量的可见性的意思是当一个线修改一个共享,另外一个线程能个修改的。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令, 通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情[1]。
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。锁住总线,导致其他CPU不能访问总线,不能访问总线就意味着不能访问系统内存。在8.1.4节有详细说明锁定操作对处理器缓存的影响,对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”;缓存一致性协议:缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

synchronized 实现同步的基础: Java中的每一个对象都可以作为锁;

.对于普通同步方法,锁是当前实例对象。
·对于静态同步方法,锁是当前类的Class对象。
·对于同步方法块,锁是Synchonized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

锁级别: 无锁状态、偏向锁状态、轻量级锁、重量级锁; 锁只能升级不能降级;

原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。

2. 处理器如何实现原子操作
首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的(单处理器),即当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁两个机制来保证(多处理器等)复杂内存操作的原子性。

总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声
言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效;

但是有两种情况下处理器不会使用缓存锁定。
1. 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
2. 有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

3. Java如何实现原子操作

(1)使用循环CAS实现原子操作: CAS实现原子操作的问题 (ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作)
(3)使用锁机制实现原子操作

第3章 Java内存模型

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步; 线程之间的通信机制有两种:共享内存和消息传递。 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
Java内存模型的抽象结构:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的
一个抽象概念,并不真实存在。

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

3.1.5 happens-before简介
从JDK 5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

与程序员密切相关的happens-before规则如下。
·程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
·监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
·volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
·传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

数据依赖性: 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间
就存在数据依赖性。

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。 这里所说的数据依赖性仅针对理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。 编译器、runtime和处理器都必须遵守as-if-serial语义。
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。
1)一个线程中的所有操作必须按照程序的顺序来执行。
2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。

64位处理器上对64位数据的写操作也不一定是原子的,要看具体虚拟机的实现, https://www.zhihu.com/question/38816432, Java语言规范里规定:

实现对普通long与double的读写不要求是原子的(但如果实现为原子操作也OK)

实现对volatile long与volatile double的读写必须是原子的(没有选择余地)

在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

class VolatileFeaturesExample {
volatile long vl = 0L; // 使用volatile声明64位的long型变量
public void set(long l) {
vl = l; // 单个volatile变量的写
}
public void getAndIncrement () {
vl++; // 复合(多个)volatile变量的读/写
}
public long get() {
return vl; // 单个volatile变量的读
}
}

假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。

class VolatileFeaturesExample {
long vl = 0L; 	// 64位的long型普通变量
public synchronized void set(long l) { // 对单个的普通变量的写用同一个锁同步
vl = l;
}
public void getAndIncrement () { // 普通方法调用
long temp = get(); // 调用已同步的读方法
temp += 1L; // 普通写操作
set(temp); // 调用已同步的写方法
}

public synchronized long get() { // 对单个的普通变量的读用同一个锁同步
return vl;
}
}

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
语义决定了界区代行具有原子性。这意味着,即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性;

volatile变量自身具有下列特性。
·可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
·原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

3.4.2 volatile写-读建立的happens-before关系
从JSR-133开始(即从JDK5开始),volatile变量的写-读可以实现线程之间的通信。 从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果:volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义。

volatile写和读的内存语义如下。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存;

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

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。

3.5.4 concurrent包的实现
由于Java的CAS同时具有volatile读和volatile写的内存语义,因此Java线程之间的通信有了下面4种方式:
1)A线程写volatile变量,随后B线程读这个volatile变量。
2)A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3)A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4)A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

3.6 final域的内存语义
对于final域,编译器和处理器要遵守两个重排序规则。
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序;

写final域的重排序规则禁止把final域的写重排序到构造函数之外。 读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作;

JSR-133对JDK 5之前的旧内存模型的修补主要有两个。
·增强volatile的内存语义。旧内存模型允许volatile变量与普通变量重排序。JSR-133严格限制volatile变量与普通变量的重排序,使volatile的写-读和锁的释放-获取具有相同的内存语义。
·增强final的内存语义。在旧内存模型中,多次读取同一个final变量的值可能会不相同。为此,JSR-133为final增加了两个重排序规则。在保证final引用不会从构造函数内逸出的情况下,final具有了初始化安全性
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java 并发