目录
JUC是什么?
java.util.concurrent包名的简写,是关于并发编程的API,包添加了多个新的线程安全集合类(ConcurrentHashMap、CopyOnWriteArrayList 和 CopyOnWriteArraySet)。这些类的目的是提供高性能、高度可伸缩性、线程安全的基本集合类型版本。
上面的都是比较官方的介绍,其实真正的JUC是什么,我们看看他的三个包就知道了
- Atomic:负责原子数据的构建
- Locks:基本的锁的实现
- Concurrent:构建一些高级的工具,比如线程池,并发队列等
其实是Concurrent包含并发工具 + atomic包+locks包,最简单的就是import的时候。
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.ConcurrentHashMap;
所以基本JUC也就分为三大功能,我们学JUC,其实就是把这三个包具体包含些什么,一些重要的技术和概念搞明白就好了
Atomic:原子性
内存可见性
在学原子性之前,我们先看一个例子,了解为什么JUC会给我们提供一个这样的功能(此例子来自Java之JUC_tiankong_12345的博客-优快云博客_java juc是什么)
public class TestVolatile {
public static void main(String[] args) {
ThreadDemo td = new ThreadDemo();
new Thread(td).start();
while (true) {
if (td.isFlag()) {
System.out.println("------------------");
break;
}
}
}
}
class ThreadDemo implements Runnable {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
flag = true;
System.out.println("flag=" + isFlag());
}
public boolean isFlag() {
return flag;
}
}
这个例子我们可以看到,在Thread.start()之后,thread等了200ms,这个时候其实flag一直是false,而这个时候main线程取到了thread中的flag,为false,在thread200ms之后开始工作,将flag置为true,但是我们的输出结果是while一直循环,不会停止,这意味着flag在thread线程中改变了,但是我main线程一直是false,我不知道你改变了状态。
所以我们这里需要知道一个概念:内存可见性,是指当某个线程正在使用对象状态而另一个线程在同时修改该状态,需要确保当一个线程修改了对象状态后,其他线程能够看到发生变化。
这个问题通过同步可以解决,除此之外,JUC提出了一种更加轻量级的解决方案,volatile变量。
首先,我们先用同步的方式解决,其实只要把td这个变量用synchronized锁起来就好了
public static void main(String[] args) {
ThreadDemo td = new ThreadDemo();
new Thread(td).start();
while (true) {
synchronized (td) {
if (td.isFlag()) {
System.out.println("------------------");
break;
}
}
}
}
除此之外,还有volatile方法,我们将flag设为volatile变量其实也能得到相同的结果。
private volatile boolean flag = false;
得到结果
原子性
那既然在内存可见性上volatile和synchronized都有一样的效果,我是不是可以用volatile替代synchronized?这个问题我们同样看个例子:i++
实际上i++可以分为三步,读-改-写,先读取i,然后+1,再写入,平时单线程的时候当然没问题,那么如果碰到多线程呢?
public class TestAtomicDemo {
public static void main(String[] args) {
AtomicDemo ad = new AtomicDemo();
for (int i = 0; i < 5; i++) {
new Thread(ad).start();
}
}
}
class AtomicDemo implements Runnable{
private int serialNumber = 0;
@Override
public void run() {
try { Thread.sleep(200); } catch (InterruptedException e) {}
System.out.println(Thread.currentThread().getName() + ":" + getSerialNumber());
}
public int getSerialNumber(){ return serialNumber++; }
}
看到下面的结果,问题就很明显了,我本来想让他们一次输出0-5的话,这样就完全失去秩序了,为什么会这样呢?很简单,当serialNumber=0的时候,在有一个线程4读取这个数值的时候,线程0和1也同时读取了这个数值,在线程4变成2之后,0和1并没有检测到这个改变。
那么这个时候,我们想到volatile,给serialNumber加个volatile看看。
private volatile int serialNumber = 0;
为什么结果还是会有检测不到值改变的现象?第一个我们要注意的是,内存可见性,是当一个线程要读取这个数的时候,一定要在读操作还没进行的时候,他才会去检查其他线程里是否改变了这个值,在上面例子里,flag在第一次循环时,main线程已经把这个变量存到线程的私有内存中了,如果flag没有加volatile的话,就算main线程后面多次读取这个值,他也不会去检查thread中是否将这个值改变了,但是加了volatile他在多次读取的时候就会去检查修改,所以在这种情况下volatile可以保证数据的同步。
但是在i++中呢?当我1线程读了serialNumber 之后,还没进行修改和写入,我这个时候0线程也读取了这个数,我只有这一个读操作,就算线程1改变了serialNumber ,因为线程0读操作已经结束了,所以就算serialNumber 是volatile类型的,我没有读操作,也不会去检查是否有线程改变了serialNumber。
所以我们说,volatile可以保证内存可见性,但是不能保证原子性,因为i++其实可以分成三个原子性操作,读-改-写。
这里我们看一下什么叫原子性操作?看个例子
A想要从自己的帐户中转1000块钱到B的帐户里。那个从A开始转帐,到转帐结束的这一个过程,称之为一个事务。在这个事务里,要做如下操作:
1. 从A的帐户中减去1000块钱。如果A的帐户原来有3000块钱,现在就变成2000块钱了。
2. 在B的帐户里加1000块钱。如果B的帐户如果原来有2000块钱,现在则变成3000块钱了。
如果在A的帐户已经减去了1000块钱的时候,忽然发生了意外,比如停电什么的,导致转帐事务意外终止了,而此时B的帐户里还没有增加1000块钱。那么,我们称这个操作失败了,要进行回滚。回滚就是回到事务开始之前的状态,也就是回到A的帐户还没减1000块的状态,B的帐户的原来的状态。此时A的帐户仍然有3000块,B的帐户仍然有2000块。
我们把这种要么一起成功(A帐户成功减少1000,同时B帐户成功增加1000),要么一起失败(A帐户回到原来状态,B帐户也回到原来状态)的操作叫原子性操作。
如果把一个事务可看作是一个程序,它要么完整的被执行,要么完全不执行。这种特性就叫原子性。
那么volatile不能解决原子性的问题,是不是又得回到synchronized用锁去做?当然不可能,在JUC中的java.util.concurrent.atomic包中就提供了一些原子变量来结合volatile,对原子性和内存可见性进行实现,这些原子性类型就包括
AtomicInteger | int |
AtomicLong | long |
AtomicBoolean | boolean |
AtomicInteferArray | int[] |
AtomicLongArray | long[] |
AtomicRefrence | 引用数组 |
所以我们把volatile改成AtomicInteger类型,那么这个问题就解决了
private AtomicInteger serialNumber = 0;
public int getSerialNumber(){ return serialNumber.getAndIncrement(); }
//getAndIncrement就时AtomicInteger类中保证原子性的++函数
Atomic底层CAS实现原理
我们知道JUC.atomic给我们提供了原子性类型去解决原子性问题,这样一来就可以结合volatile,实现同步功能,其中实现原理中,CAS算法最为关键。
CAS算法全称为(Compare-And-Swap)其实是硬件对于并发操作的支持,CAS包含了三个操作数,1.内存值V,2.预估值,3.更新值B,为什么叫CAS呢,其实就是比较然后判断,再交换,所以他的原理其实也很简单,当且仅当V==A时,进行V=B操作,否则不会执行任何操作。那么我们来看看CAS的具体原理吧。
CAS被认为是乐观锁,乐观锁听名字也很简单,以一种更加乐观的态度对待事情,认为自己可以操作成功。当多个线程共享一个资源的时候,仅有一个线程同一时间获取锁成功,在乐观锁中,其他线程发现自己无法获得锁时,他会直接返回,选择再次获取锁,或者直接退出。
有乐观锁当然就有悲观锁了。我们之前一直说的synchronized在线程竞争压力大的情况下,synchronized内部会升级为重量级锁,此时只有一个线程可以进入代码块执行,如果这把锁不能释放,悲观锁不像乐观锁直接退出,其他进程会一直阻塞下去,这样的锁被称为悲观锁。悲观锁会一直阻塞导致系统上下文切换,系统的性能开销很大。
而乐观锁的核心算法就是CAS,不仅仅是Atomic类型数据的实现底层,而且是我们接下来会讲到的Lock锁的底层。
我们就从AtomicInteger来看看CAS中的Compare-And-Swap到底是怎么实现的,既然AtomicInteger是用来替代int类型的原子类型的变量,那么他的内部属性肯定还有一个int类型的value,并且为了实现内存可见性,让他变成volatile类型是必须的,然后AtomicInteger在这个基础上加一些保证原子性的操作,就有了AtomicInteger,是不是如果上面的例子看懂了,这个就很好理解了。所以我们看看AtomicInteger中的三个重要的变量。
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;
private static final long valueOffset;
其中,value就是在初始化时将int类型的数据传入进来的替代变量,而Unsafe是用c语言实现的类,其主要目的是结合valueOffset(偏移量)用来对内存进行操作,而AtomicInteger的操作基本也是在Unsafe这个对象上进行的。(下面为初始化函数)
public AtomicInteger(int initialValue) {
value = initialValue;
}
那么实现CAS思想的最关键的还是value的自加过程,在上面的例子中,其实我们就用到了这个函数,我们可以看到他其实是调用了unsafe的一个函数,那么我们直接看unsafe的函数就行了。
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
这个函数主体是一个循环,实现的就是CAS的思想,三个操作数1.内存值V,2.预估值,3.更新值B,当且仅当V==A时,进行V=B操作,否则不会执行任何操作。在这里var1其实我们看getAndDecrement传进来的其实是Atomic的变量,所以通过var1对象和var2偏移量,先得到var5这个内存中的对象,然后通过compareAndSwapInt函数进行比较操作,如果相等那么更新var5为var5+var4,如果是自加那么var4就是1,如果不相等就一直将do-while循环进行下去。
比如有A,B两个线程,一开始从主内存中拷贝了原值3,A线程执行到var5=this.getIntVolatile = 3,此时A线程挂起,B修改为原值4,B线程执行完毕,由于加了volatile,这个修改是能被A发现的,所以执行compareAndSwapInt函数的时候发现这个时候因为B线程改了值,内存值不等于3了,然后就开始循环,重新从内存中获得。
我们回忆一下,开头讲的第一个例子,用while循环的时候,如果不用volatile类型的话,循环永远不会停止,因为检测不到修改,用了volatile类型之后就能停止循环了,但是在原子性这个问题上,volatile又不够了,就是因为只有在不断判断和访问的时候,volatile才能不断的检测到修改值,所以这个循环,其实跟第一个例子差不多,在加了volatile类型之后,我们需要不断的去检查是否有修改值,不然几遍加了volatile类型,也检测不到修改。
所以总结一下就是CAS其实就是在不加锁的前提下,让线程不断的去尝试,如果发生冲突(值不相等)那我就继续尝试,如果相等了,我就继续我的线程。
Locks:锁
在Java中的锁可以分为同步锁和JUC包中的锁,同步很简单,从Java1.0就开始可用了,使用synchronized关键字来进行同步并且实现对竞争资源的互斥访问。同步锁的原理是对每一个对象,有且仅有一个同步锁,这个同步锁同一时间能且只能被一个线程获取,获取到锁的线程就能进行CPU调度,从而在CPU上执行,没有获得同步锁的线程必须等到,直到获取同步锁之后才能继续进行。
而JUC包中的锁功能更强大,他提供了一个锁的框架,能让我们更好的使用锁,只是因为是一个框架所以请起来更复杂罢了。
我们主要来看看locks中的几种锁:ReentrantLock,ReadWriteLock,ReentrantReadWriteLock
在这之前我们看看他们共同的接口Lock接口,其实lock接口并不复杂,就是锁的日常,获取锁,得不到锁的时候怎么办,释放锁无非是这几个操作。
public interface Lock {
//获取锁,获取到锁后返回
//注意一定要记得释放锁
void lock();
//可中断的获取锁
//获取锁的时候,如果线程正在等待获取锁,则该线程能响应中断
void lockInterruptibly() throws InterruptedException;
//尝试获取锁,当线程获取锁的时候,获取成功与否都会立即返回
//不会一直等着去获取锁
boolean tryLock();
//带有超时时间的尝试获取锁
//在一定的时间内获取到锁会返回true
//在这段时间内被中断了,会返回
//在这段时间内,没有获取到锁,会返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//释放锁
void unlock();
//获取一个Condition对象。
Condition newCondition();
}
ReentrantLock可重入锁
首先,我们看看可重入锁的概念:可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。如果看上去很抽象,其实对常见的场景,就是我一个线程需要递归的访问一个资源多次的时候,如果是不可重入锁的话,那就是线程A在第一次递归的时候获取了某个对象的锁,但是我第二次递归的时候,第一次的锁还没有释放,这样一来因为不可重入,我第二次递归没办法进行,递归没办法进行第一次递归就不会释放锁,这样一来就成了死锁。
可重入锁就相对应的解决了这个问题,可重入的实现和不可重入基本一致,就是内部多了一个计数器和一个被锁的线程用来记录,这样一来如果一个线程多次访问一个对象的锁,计数器就加一,释放一个就减一,如果计数器为0,就可以被其他线程访问了,这样一来就不会因为递归产生死锁的状态。
而ReentrantLock和synchronized一样,都是可重入锁,尽管都是可重入锁,但是ReentrantLock的功能比synchronized更强大,synchronized是依赖与JVM来实现功能,由编译器来实现锁的添加和释放,所以更自动,ReentrantLock是根据API来实现的,所以更手动,虽然更麻烦一点,但是能实现的功能也更多。
第一个,ReentrantLock支持公平模型和非公平模型两种获取锁的方式,公平模型和非公平模型理解很简单,ReentrantLock会给请求获得锁的线程一个队列,如果是公平模型先来的排在前面,等锁释放了,先到先得,这就是公平模型,不公平模型那就是锁释放的时候在排队的线程一起重新竞争锁的使用权,这种情况下,后来的线程也可能比先来的线程先获得锁的使用,所以称为非公平模型。其中AQS(队列同步器)就是用一个FIFO队列结合CAS实现公平模型的。
第二个,为了避免出现死锁的状态,ReentrantLock可以允许线程放弃等待,通过lock.lockInterruptibly()也就是Lock接口中的方法来实现这个机制。
第三个,我们看到Lock接口中还有一个newCondition方法,这个在ReentrantLock中可以允许一个ReentrantLock同时锁住多个对象,让用不用的条件来唤醒不同的对象。
ReadWriteLock读的共享锁和写的独占锁接口
ReadWriteLock这个接口解决了synchronized读与读互斥的问题,在ReadWriteLock锁的对象上,多个线程可同时进行读操作,并不会互斥,但是写还是会互斥
ReentrantReadWriteLock可重入读写锁
Reentrant的意思就是可重入,那么ReentrantReadWriteLock就很好理解了,可重入加上读写锁,不仅实现了可重入和AQS实现的公平模型。而且可保证读的效率比之前更高,当然写还是要实现互斥,在ReentrantReadWriteLock中允许在重入的时候允许写操作的互斥特性降级为读锁,也就是说当你同一个线程进行写操作的时候,允许你一个线程多次使用锁的时候同时进行写操作
Concurrent构建一些高级类的工具
Concurrent中并发工具也分为三类,并发容器,执行框架以及线程池,还有并发工具类
1.并发容器
其中ConcurrentHashMap最常用,不懂的可以看看这篇文章,我们可以知道他是一个分段锁,并且每个segment是继承了ReentrantLock的,所以底层也很容易知道,CAS加上AQS的优先队列。
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
而其中CopyOnWrite+容器的容器都是用了CopyOnWrite这个方法来实现线程安全,比如add,set,remove,都会先进行Copy,再修改后再替换原来的数组,如下
public boolean add(E e) {
synchronized (lock) {
Object[] elements = getArray();
int len = elements.length;
// 拷贝
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
// 替换
setArray(newElements);
return true;
}
}
final void setArray(Object[] a) {
array = a;
}
2.执行框架以及线程池
在这里就涉及到线程池的一些知识了,我们在另外一篇文章详细讲一下线程池最主要的几个参数以及线程池怎么实现
3.并发工具类
并发工具类中CountDownLatch用的比较多,是一个同步工具类,用来协调多个线程之间的同步,或者说线程间的通信。那么这个CountDownLatch有什么用呢,我们看一个例子。
import java.util.concurrent.atomic.AtomicInteger;
public class TestThread2 {
public static void main(String[] args) {
Station1 st1 = new Station1("station 1");
Station1 st2 = new Station1("station 2");
Station1 st3 = new Station1("station 3");
st1.start();
st2.start();
st3.start();
}
}
class Station1 extends Thread {
public Station1(String name){
super(name);//给线程命名
}
static int tick = 20;//保持多个站台线程间票量一直要用static
Object lock = "lock";
@Override
public void run(){
while(tick > 0){
synchronized (lock){
if(tick > 0){
System.out.println(getName() + " 卖出了第" + tick + "张票" );
tick--;
//System.out.println("剩下 " + tick + " 张票" );
}else {
System.out.println("票卖完了");
}
try {
sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
}
}
看看这个例子的输出
很明显的问题就是,我本来想记录三个线程总共运行的时间,但是在我三个线程开启之后,主线程就直接输出了,这就有一个问题,我怎么让主线程知道其他线程已经结束了?然后我再进行目前主线程的输出?这个时候就用到了CountDownLatch
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class TestThread2 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(3);
Station1 st1 = new Station1("station 1",countDownLatch);
Station1 st2 = new Station1("station 2",countDownLatch);
Station1 st3 = new Station1("station 3",countDownLatch);
long time = System.currentTimeMillis();
st1.start();
st2.start();
st3.start();
countDownLatch.await();
System.out.println((System.currentTimeMillis() - time));
}
}
class Station1 extends Thread {
static CountDownLatch countDownLatch;
Object lock = "lock";
static int tick = 20;//保持多个站台线程间票量一直要用static
public Station1(String name, CountDownLatch countDownLatch){
super(name);//给线程命名
this.countDownLatch = countDownLatch;
}
@Override
public void run(){
while(tick > 0){
synchronized (lock){
if(tick > 0){
System.out.println(getName() + " 卖出了第" + tick + "张票" );
tick--;
//System.out.println("剩下 " + tick + " 张票" );
}else {
System.out.println("票卖完了");
}
try {
sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
countDownLatch.countDown();
}
}