Java并发编程的艺术

1.    并发编程的挑战

1.1   上下文切换

CPU通过给每个线程分配时间片来实现线程间的切换,一般时间片为几十毫秒。当切换线程时,会在切换前保存上一个任务的状态,并加载这个任务的状态,从保存到再加载的过程就称为一次上下文切换。

1.2    减小上下文切换的方式

一般而言,减小上下文切换的方式有:无锁并发编程、CAS算法、使用最小线程和使用协程。

无锁并发编程:当多线程竞争锁的时候,会引起锁等待资源,容易引起上下文切换,所以处理数据时考虑各种办法避免使用锁,如数据分段,不同线程处理不同段的数据。

CAS算法:使用Atomic包的CAS算法来更新数据,不需要加锁。

最小线程:避免创建不必要的线程,例如创建大量线程,但是都处于等待状态,则不可取。

协程:在单线程内实现多任务的调度,并在单线程中维持多个任务的切换。

1.3 线程安全性

在构建稳健的并发程序时,必须正确地使用线程和锁。要编写线程安全的代码,其核心在于要对状态访问操作进行管理特别是共享的和可变的状态的访问。共享意味着变量可以由多个线程同时访问,可变意味着变量的值在其生命周期内可以发生变化。

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

1)不在线程之间共享该状态变量;

2)将状态变量修改为不可变的变量;

3)在访问状态变量时使用同步。

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

在并发编程中,当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。要避免竞态条件问题,就必须在某个线程修改该变量之前,通过某种方式防止其他线程使用该变量。例如通过AtomicLong替代long类型的计数器,能确保所有对计数器状态的访问操作都是原子的。在实际情况中,应尽可能使用线程安全对象来管理类的状态。

当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某个变量时,需要在同一个原子操作中对其他变量同时进行更新,这样才能保证状态的一致性。

Java提供一种内置锁机制来支持原子性:同步代码块。它相当于一种互斥体(互斥锁),这意味着最多只有一个线程能持有这种锁。但这种方法同时只能允许一个线程执行同步块的内容,并发性差,服务的响应性非常低。

一种常见的加锁的约定是,将所有可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得该对象上不会发生并发访问。在这种情况下,对象状态中的所有变量都将由对象的内置锁保护起来。

 

2.    Java并发机制的底层实现

2.1   volatile的实现

volatile是一个轻量级synchronized操作,它能在并发环境下保证不同线程对共享变量的可见性,即当某个线程修改一个共享变量的值时,其他线程能立即读到这个修改的新值。另外它还会禁止指令重排序,在一定程度上保证有序性。

有volatile修饰的共享变量在转换成汇编语言时会加一个lock前缀指令,lock前缀指令相当于一个内存屏障(也叫内存栅栏),它有3个功能:

1.     确保指令重排序时不会把其后面的指令拍到内存栅栏之前的位置,也不会把之前的指令排在内存栅栏之后,保证执行到内存栅栏这句指令时,在它之前的操作已经完成。

2.     强制将当前处理器缓存行中的数据写入系统内存。

3.     如果是写操作,会导致其他CPU对应中的缓存行无效。

当其他处理器发现自己缓存行对应的数据无效时,就会重新从系统内存把数据读入到处理器缓存中。

2.2 synchronized的实现

Java对象头和monitor是实现synchronized的基础,

2.3  锁优化

锁一共有4种状态,级别由低到高依次是:无锁状态、偏向锁状态、轻量锁状态和重量级锁状态,这几个状态会随着竞争激烈情况逐渐升级。另外锁可以升级但是不能降级。

偏向锁:目的是在无多线程竞争的情形下,尽量减小不必要的轻量级锁执行。当一个线程访问同步块并获取锁时,会在对象头和栈帧中记录线程的ID信息,以后该线程再次请求锁时,先判断markWord是否为可偏向状态,如果是则测试锁偏向的线程id是否为当前线程id,如果是则执行同步代码块。如果不是则进行CAS操作竞争锁。

偏向锁采用竞争才会释放锁的机制,当有其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁。

3. Java内存模型

4. Java并发编程基础

6. Java并发编程容器和框架

6.1 HashMap、HashTable和ConcurrentHashMap比较

HashMap是非线程安全类,在并发环境下进行put操作会导致死循环,原因在于多线程会导致Hash Map的Entry链表形成环形数据结构,那么next节点永不为空,产生死循环。

HashTable使用synchronized来保证线程安全,但在线程竞争激烈的情形下,HashTable的效率会非常低下,当一个线程访问Hash Table的同步方法时,其他线程只能阻塞或轮询获取锁。

ConcurrentHashMap使用锁分段技术来提高并发访问效率。它将数据分成多段,每段数据配置一把锁,当某线程访问其中一段数据时,其他段的数据能被其他线程访问到。

ConcurrentHashMap是由Segment数组和HashEntry数组构成的。Segment扮演锁的角色,HashEntry则用于存放键值对。每个Segment包含一个HashEntry,HashEntry里面的数据也由该Segment守候。

6.1.1 ConcurrentHashMap初始化

6.1.2 定位Segment

定位Segment时是通过散列算法定位到具体某个Segment的,具体而言是对元素的hashCode进行一次再散列。进行再散列的原因在于减小散列冲突,使元素能均匀分配在不同Segment中。

6.1.3 ConcurrentHashMap基本操作

1. get操作:先通过散列算法定位到具体Segment,然后再通过散列算法定位到具体元素。get操作过程中不需要加锁,原因在于get方法中涉及的共享变量都定义成volatile类型,如统计当前segment大小的count字段和用于存储HashEntry的value字段。

2. put操作:put方法需要对共享变量进行写入操作,因此会加锁。首先定位到Segment时,会判断是否需要对HashEntry进行扩容,第二步是定位到添加元素的位置,然后将元素放入HashEntry中。

3.size操作:统计ConcurrentHashMap的元素数目需要统计所有Segment,如果全部加锁则效率太低。Put、remove、和clean方法时会将modCount变量进行加1,统计size前后比较modCount是否发生变化,从而得知容器大小是否有变化。

6.2 ConcurrentLinkedQueue

并发变成中需要使用线程安全的队列,那么常见的实现方式是使用阻塞算法和非阻塞算法。阻塞算法可以入队出队共用同一个锁,或入队出队各自用单独的锁来实现。非阻塞算法则可以依靠CAS算法来实现。

ConcurrentLinkedQueue则是使用非阻塞算法来实现线程安全的。

6.3 Java中的阻塞队列

阻塞队列(blockingQueue)常用于生产者和消费者场景,

6.4 Fork/Join框架

Fork/Join框架是Java 7提供的用于并行执行任务的框架,即把一个大任务分割成若干个小任务,最终汇总每个小任务的结果后得到大任务的结果。

7. Java中的常见原子操作

7.1 基本类型类

AtomicBoolean,AtomicInteger,AtomicLong

常用方法有:

int addAndGet(intdelta);

booleancompareAndSet(int expect, int update);

intgetAndIncrement();

void lazySet(intnewValue);

intgetAndSet(int newValue);

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值