并发编程简单总结
单核CPU可以多线程么?
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现
这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切
换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个
任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这
个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
为什么并发执行的速度会比串行慢
这是因为线程有创建和上下文切换的开销
如何减少上下文切换
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一
些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。 - CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这
样会造成大量线程都处于等待状态。 - 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
避免死锁
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
java字节码描述
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节
码,最终需要转化为汇编指令在CPU上执行。
volatile关键字
被volatile关键字修饰的变量,再多线程中,一个线程修改他会立即被其他线程看到
有volatile变量修饰的共享变量进行写操作的时候会多出lock前缀指令:
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
处理器为了提交效率,读取缓冲,当有写请求时,如果带上了volatile关键字,会发送一个前缀lock指令,将这个变量所在的缓冲行写到系统内存,但是有很多处理器,就会有多个缓冲行,他是怎么保证缓存一致的呢,每个处理器会嗅探总线传播的数据是不是过期了,如果缓存行的对应的内存地址被修改,那么就把缓存行设置为无效,当下次访问内存的时候,强制写到缓存行。
在每次volatile写之前会加上storestore屏障,写之后会加上storeload屏障。
在每次volatile读之前会加上loadload屏障,读之后加上loadstore屏障。
目的是为了指令重排序。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以
确保对整个临界区代码的执行具有原子性。
synchronized的实现原理与应用
具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是Synchonized括号里配置的对象。
synchronized内部实现
通过 javap -v XXX.class文件可以发现
代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是ACC_SYNCHRONIZED方法获取到的,无论哪种方式都是通过一个对象的monitor进行获取
任何线程对同步代码的访问,都要先获取监视器,如果获取失败,就会放到SyncchronizedQueue队列中,当持有线程释放了锁后,会释放阻塞在队列中的线程。
synchronized用的锁是存在Java对象头里的,HashCode、分代年龄和锁标记位。
偏向锁
当一个线程在访问同步代码块的时候,对象头mark_word会存储当前线程的Id,在重复获取锁的时候不需要cas进行获取,而是检查mark_word是否有当前线程的偏行锁,如果没有,检查mark_word是否是1,不是的话,cas竞争,将设置为1,如果为1,那么就将对象头的偏向锁指向当前线程。
偏向锁的撤销只有在竞争的时候才会释放,只有等到安全点的时候(这个时间没有执行的字节码的时候),首先会暂停拥有偏向锁的线程,检查拥有偏向锁的栈,拥有的将会被执行,最后会唤醒竞争的线程。
java保证原子性有哪些方式
简单分为两种:加锁和使用自旋cas
JVM锁除了偏向锁,都是使用cas实现的加锁
cas
比较预期值和更新之后的值是否相等,如果相等的话,就更形
cas 的缺点
- ABA问题,他是比较当前值和预期的值是否相等,如果一个值原来是A,变成了B,又变成了A,那么cas检测时,会发现这个值是没有变化的,但是实际上却变化了,ABA问题的解决的方法时加上版本号,每次更新的时候版本号会加一,JDK1.5出现的AtomicStampedReference来解决ABA问题,比较了当前标志是否等于预期的标志。
- 循环时间开销大。自旋时间太长会出现较大的CPU开销
- 只能保证一个共享变量的原子性。从JDK1.5出现了AtomicReference保证引用对象的原子性。可以把多个变量放到一个对象里面,进行cas操作。
java内存模型的基础
线程之间的通信有两种方式,分别为共享内存和消息传递。
在共享内存的并发模型中,通过共享程序的公共状态,隐式通信。
在消息传递的并发模型中,线程之间必须通知传递消息通信。
java内存模型的抽象结构
JMM定义了抽象关系,线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存,本地内存存储了该线程的读写副本。
如果线程A与线程B之间要通信的话:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
happens-before原则
从JDK 5开始,Java使用新的JSR-133内存模型(除非特别说明,本文针对的都是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。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个
操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见
一个happens-before规则对应于一个或多个编译器和处理器重排序规则
获取锁和释放锁的内存语义
释放锁的时候,会把共享变量刷到主内存里面。获取锁的时候,临界区的本地变量无效,从主存中刷到本地内存中。
aqs
Java队列同步器框架AbstractQueuedSynchronizer,AQS使用一个整型的volatile变量(命名为state)来维护同步状态。
主要包括:
- 同步队列
- 独占式同步状态获取与释放
- 共享式同步状态获取与释放
- 以及超时获取同步状态等同步器的核心数据结构与模板方法
同步队列
同步器依靠的是一个FIFO的双向队列来维持一个同步状态,当线程阻塞时,会把节点信息放到队尾(这个时候会调用compareAndSetTail,多个线程竞争,保证线程安全),当有线程释放的时候,会唤醒队头的线程(这个时候不用cas,只是头节点断开就行了,只会有一个成功)。
独占式同步状态获取与释放
在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋,移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。
ReentrantLock
ReentrantLock分为公平锁和非公平锁。
- 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
- 公平锁获取时,首先会去读volatile变量。
- 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和volatile
写的内存语义
锁释放-获取的内存语义的实现至少有下面两种方式。
- 利用volatile变量的写-读所具有的内存语义。
- 利用CAS所附带的volatile读和volatile写的内存语义。
ReadWriteLock保证HashMap解决并发安全问题
每次修改操作之前加上写锁,读之前加上读锁。
concurrent包的实现
首先,声明共享变量为volatile。
然后,使用CAS的原子条件更新来实现线程之间的同步。
同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信
AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent
包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的
final域的内存语义
线程优先级
java将线程的优先级分为1-10,默认为5,线程的优先级越高,代表获取的时间片数量越高。
线程的状态
同一时刻,线程只能拥有一个状态
- new 线程被构建,还没有调用start方法
- Runnable 运行状态,java将就绪和运行笼统称作运行中
- blocked 阻塞状态,表示线程阻塞于锁
- waiting 等待状态,表示线程需要等待其他线程发出一些动作(通知或者中断)
- time_waiting 超时等待状态,指定时间自行返回
- terminated 终止状态
daemon线程
后台线程,只能在运行前设置为daemon
等待通知机制
注意
- 使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
- 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的
等待队列 - notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或
notifAll()的线程释放锁之后,等待线程才有机会从wait()返回 - notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()
方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING变为
BLOCKED。 - 从wait()方法返回的前提是获得了调用对象的锁。
WaitThread首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁并进入了对象的等待队列WaitQueue中,进入等待状态。由于WaitThread释放了对象的锁,NotifyThread随后获取了对象的锁,并调用对象的notify()方法,将WaitThread从WaitQueue移到SynchronizedQueue中,此时WaitThread的状态变为阻塞状态。NotifyThread释放了锁之后,WaitThread再次获取到锁并从wait()方法返回继续执行。
Thread.join()的使用
如果一个线程A执行了thread.join()语句,其含义是:当前线程A等待thread线程终止之后才
从thread.join()返回,谁调用谁等待。
java并发框架
为什么要使用ConcurrentHashMap
- hashMap并发put导致死循环,因为会形成一个环形链表。
- hashTable使用synchronized,效率低
- 分段锁提升效率
- get操作
get 操作不用加锁,经过两次hash运算,第一次找到对应的segment,第二次找到对应的entry,并且都将变量声明成volatile类型,保证segment的count字段和entry的value保证内容可见性,能够被多个线程同时读,即使两个线程同时修改和读取同一变量,由于happen before原则,对volatile的写优先于读。
- put操作
需要对共享变量进行加锁,需要进行两个过程,第一,定位到segment,判断segment中的hashEntry是否需要扩容,第二,定位到对应的hashEntry
值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。
如何扩容,创建一个是原来两倍的数组,将原来数组的元素放到新的数组里面,为了高效,只是对segment进行扩容。
- size 操作
先尝试通过不锁柱segment的方式统计两次各segment的大小,如果在统计的过程中,count发生了变化,则加锁统计所有segment的大小
如何判断容器是否发生变化了呢,有一个变量modCount,当有remove、clean、add操作时,modCont会加1,前后比较modCount的值即可判断
ConcurrentLinkedQueue
非阻塞的无界队列,cas实现。
阻塞队列
支持两个操作:
- 支持添加时队列如果满了,阻塞当前线程,直到队列不满。
- 支持从队列中移除元素,如果队列为空,获取元素的线程会等待到队列非空
抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|
add | offer | put | offer(e,time) |
remove | poll | take | poll(e,time) |
线程池
好处
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,
还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。但是,要做到合理利用
线程池,必须对其实现原理了如指掌。