从Java多线程基础到Java内存模型;从synchronized关键字到Java并发工具包JUC。
我们不生产知识,我们只做知识的搬运工!
线程与进程的不同点:
起源不同。先有进程后有线程。由于处理器的速度远远大于外设,为了提升程序的执行效率,才诞生了线程。
概念不同。进程是具有独立功能的程序运行起来的一个活动,是操作系统分配资源和调度的一个独立单位;线程是CPU的基本调度单位。
内存共享方式不同。不同进程之间的内存数据一般是不共享的(除非采用进程间通信IPC);同一个进程中的不同线程往往会共享:
拥有的资源不同。线程独有的内容包括:
进程和线程的数量不同。
线程和进程创建的开销不同。
Java中没有协程的概念,协程往往指程序中的多个线程可以映射到操作系统级别的几个线程,Java中的线程数目与操作系统中的线程数目是一一对应的。
创建线程只有一种方式就是构造Thread类。实现线程的执行单元有两种方式:
从3个角度可以得到实现Runnable接口来完成多线程编程优于继承Thread类的完成多线程编程:
同步与异步:
同步是指被调用者不会主动告诉被调用者结果,需要调用者不断的去查看调用结果
异步是指被调用者会主动告诉被调用者结果,不需要调用者不断的去查看调用结果
线程的正确启动与停止:
线程的正确启动方法是start()而不是run()。start()方法的本质是请求JVM来运行当前的线程,至于当前线程何时真正运行是由线程调度器决定的。start()方法的内部实现主要是包括三个步骤:一是检查要启动的新线程的状态,二是将该线程加入线程组,三是调用线程的native方法start0()。
线程的正确停止方法是:使用interrupt()来通知,而不是强制结束指定线程。
public class JavaDemo implements Runnable {
@Override
public void run() {
while (true) {
if (Thread.currentThread().isInterrupted()) {
break;
}
System.out.println("go");
interrupt();
}
}
public void interrupt() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println("出现异常,记录日志并且停止");
Thread.currentThread().interrupt();
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new JavaDemoSi());
thread.start();
thread.sleep(1000);
thread.interrupt();
}
}
线程的六种生命周期:
常见方法:
wait()方法:在同步代码块synchronized(object){}
中的线程A已经获取到锁时,其他线程不能获取当前锁从而会阻塞进入BLOCKED状态;当线程A执行object.wait()
时,线程A持有的锁会释放,此时其他线程获取到object锁;其他线程代码中执行了object.notify()
方法时,线程A会重新获取到object锁,可以进行线程的调用。
注意notify()、notifyAll()方法必须要在wait()方法之后调用,若顺序改变则程序会进入永久等待。
park()方法:在线程中调用LockSupport.park()进行线程的挂起,在其他线程中调用LockSupport(已挂起的线程对象)进行线程的唤醒。park()和unpark()是基于许可证的概念存在的,只要调用了unpark()在一次park()中就可以实现线程的一次唤醒(这里的一次是指线程只要调用了park()就要调用unpark(),不能实现调用多次unpark()后面的park()多次调用就可以直接实现线程的唤醒),park()和unpark()没有调用顺序的限制。
注意park()、unpark()方法不是基于监视器锁实现的,与wait()方法不同,park()只会挂起当前线程并不会对锁进行释放。在线程中使用synchronized关键字的内部调用了park()容易导致死锁。
几个常见特性: 原子性、内存可见性和重排序。
原子性:
原子(Atomic)操作指相应的操作是单一不可分割的操作。
在多线程中,非原子操作可能会受到其他线程的干扰,使用关键字synchronized
可以实现操作的原子性。synchronized
的本质是通过该关键字所包括的临界区的排他性保证在任何一个时刻只有一个线程能够执行临界区中的代码,从而使的临界区中的代码实现了原子操作。
内存可见性:
CPU在执行代码时,为了减少变量访问的时间消耗会将代码中访问的变量值缓存到CPU的缓存区中,代码在访问某个变量时,相应的值会从缓存中读取而不是在主内存中读取;同样的,代码对被缓存过的变量的值的修改可能仅仅是写入缓存区而不是写回到内存中。这样就导致一个线程对相同变量的修改无法同步到其他线程从而导致了内存的不可见性。
可以使用synchronized
或volatile
来解决内存的不可见性问题。两者又有点不同。synchronized
仍然是
通过将代码在临界区中对变量进行改变,然后使得对稍后执行该临界区中代码的线程是可见的。volatile
不同之处在于,一个线程对一个采用volatile关键字修饰的变量的值的更改对于其他使用该变量的线程总是可见的,它是通过将变量的更改直接同步到主内存中,同时其他线程缓存中的对应变量失效,从而实现了变量的每次读取都是从主内存中读取。
指令重排序:
在CPU多级缓存场景下,当CPU写缓存时发现缓存区正在被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行。运行时指令重排要遵循as-if-serial语义,即不管怎么重排序,单线程程序的执行结果不能改变并且编译器和处理器不会对存在的数据依赖关系的操作做重排序。
指令的重排序导致代码的执行顺序改变,这经常会导致一系列的问题,比如在对象的创建过程中,指令的重排序使得我们得到了一个已经分配好的内存而对象的初始化并未完成,从而导致空指针的异常。volatile
关键字可以禁止指令的重排序从而解决这类问题。
总之,synchronized
可以保证在多线程中操作的原子性和内存可见性,但是会引起上下文切换;而volatile
关键字仅能保证内存可见性,但是可以禁止指令的重排序,同时不会引起上下文切换。
首先介绍Java内存模型的特性
下面介绍内存模型图
基于JMM,Java提供了多种除了锁之外的同步机制来保证线程安全性。Java提供的TreadLocal以及前面概念中提到的volatile就是两种策略。
下面先介绍volatile关键字,ThreadLocal在下文并发工具类中介绍
volatile最主要的就是实现了共享变量的内存可见性,其实现的原理是:volatile变量的值每次都会从高速缓存或者主内存中读取,对于volatile变量,每一个线程不再会有一个副本变量,所有线程对volatile变量的操作都是对同一个变量的操作。
volatile变量的开销包括读变量和写变量两个方面。volatile变量的读、写操作都不会导致上下文的切换,因此volatile的开销比锁小。但是volatile变量的值不会暂存在寄存器中,因此读取volatile变量的成本要比读取普通变量的成本更高。
volatile常被称为"轻量级锁"。
互斥同步是指多个线程对共享资源是独占的,当一个线程获得共享资源时,其他所有的线程都将处于等待获取状态,不同线程之间是敌对的。
根据不同的分类标准存在多种锁类型,对于一种确定的锁可以同时属于下面的多种类型:
多个线程能否共享一把锁:可以实现共享的称为共享锁;不可以实现共享的称为排他锁。共享锁又称为读锁,每一个线程都可以获取到读锁,之后可以查看数据但是无法修改和删除数据。
synchronized属于排他锁**。 **
ReentrantReadWriteLock`同时具备共享锁和排他锁,其中读锁是共享锁,写锁是排他锁。
线程要不要锁住同步资源:锁住同步资源的称为悲观锁(又称为互斥同步锁);不锁住同步资源的称为乐观锁(又称为非互斥同步锁)。
优缺点:
悲观锁的性能相对较低:当发生长时间锁等不到释放或者直接出现死锁时,等待锁的线程永远得不到执行;同时悲观锁存在阻塞和唤醒这两种状态都是会消耗资源的;此外使用了悲观锁,线程的优先级属性设置将会失效。
相对于悲观锁而言,乐观锁性能较高,但是如果获取锁的线程数量过多,那么乐观锁会产生大量的无用自旋等消耗,性能也会因此而下降
悲观锁适用于并发写入多或者临界区持锁时间比较长的情形
乐观锁适用于并发写入少、并发读取多的情形
synchronized
和Lock
都属于悲观锁。
原子类和并发容器工具都采用了乐观锁的思想
乐观锁基于CAS算法实现。
CAS算法:
CAS(Compare and Swap),即比较并交换。
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
当然CAS除了有上面提到的乐观锁的缺点外,CAS还容易出现ABA问题。即可能存在其他线程修改过预期值执行过其他操作之后又写会预期值,这样反而不会被察觉。解决ABA问题的一个好方式就是增加版本号version字段,通过每次更新操作都修改version字段以及每次更新之前都检查version字段来保证线程执行的安全性
同一个线程是否可以重复获取同一把锁:可以重复获取的称为可重入锁;不可以重复获取的称为不可重入锁
可重入锁可以有效的避免死锁,当一个线程获取到锁时,可以继续获取该锁,而不会出现当前线程等待当前线程释放锁这一情况的发生。
synchronized
和ReentrantLock
都属于可重入锁。
多个线程竞争时根据是否排队:通过排队来获取的称为公平锁;先尝试插队,插队失败再排队的称为非公平锁
ReentrantLock既可以实现公平锁又可以实现非公平锁,通过指定ReentrantLock构造方法中fair的参数值来实现公平与非公平的效果
是否可以响应中断:可响应中断的称为可中断锁;不可响应中断的称为非可中断锁
等锁的过程不同:等锁的过程中如果不停的尝试而非阻塞称为自旋锁;等锁的过程中如果阻塞等待称为非自旋锁
方法锁,即默认锁对象为this当前实例对象。同一个实例对象下的实例方法共享同一把锁,不同的实例对象的实例方法锁不同。
class SynchronizedDemo1 {
public synchronized void index1() {
//do something...
}
public synchronized void index2() {
//do something...
}
}
class SynchronizedDemo2 {
public synchronized void index1() {
//do something...
}
public synchronized void index2() {
//do something...
}
}
以上代码中,SynchronizedDemo1实例对象demo1的方法index1和index2共享同一把锁,SynchronizedDemo2实例对象demo1的方法index1和index2共享同一把锁,多个线程访问同一个对象下的synchronized修饰的方法时是互斥同步的,访问不同对象的synchronized修饰的方法互不干扰
同步代码块锁,即自己指定锁对象。
class SynchronizedDemo1 {
public synchronized void index() {
synchronized(this){
//do something...
}
}
}
以上代码中,只有获得了当前对象锁的线程才能执行同步代码块中的代码,同步代码块的出现是为了减小方法锁的粒度,提高性能
synchronized修饰静态的方法。多个线程访问同一类的不同实例对象的静态方法时,由于静态方法是类级别的而不是对象级别的,所以即便是不同对象,方法之间的访问也是互斥同步的
指定的锁为Class对象。
class SynchronizedDemo1 {
public synchronized void index() {
synchronized(SynchronizedDemo1.class){
//do something...
}
}
}
以上代码中,只有获得了当前类的Class对象锁的线程才能执行同步代码块中的代码,同步代码块的出现是为了减小方法锁的粒度,提高性能
在jdk1.5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,Lock接口提供了与synchronized关键字类似的同步功能,但需要在使用时手动获取锁和释放锁,也正因为如此,基于Lock接口实现的锁具备更好的可操作性。
Lock接口中的方法:
lock()
: 此方法用于获取锁,如果锁已被其他线程获取,那么线程进入等待状态,与synchronized不同的是:当获取到锁并且在执行任务中发生了异常,synchronized会自动释放锁而lock()方法获取到的锁不会自动释放。使用lock()必须在try…finally…中手动释放。tryLock()
:由于lock()不能被中断,所以一旦陷入死锁,lock()就会陷入永久等待中;tryLock()方法是一种更为优雅的使用方式,tryLock()用来尝试获取锁,如果当前锁没有被其他线程占用,那么获取锁成功并立刻返回true,否则立刻返回false表示获取锁失败。ReetrantLock 是基于Lock接口最通用的实现,在上文中在介绍锁分类时也已经多次提到过ReentrantLock,因此也了解过其许多特性,由于ReentrantLock非常值得深入探究,在此也不在一文中过多阐述,在此给出一个链接进行参看:
[深入ReentrantLock]https://blog.csdn.net/fuyuwei2015/article/details/83719444#commentBox
读写锁是一种改进型的排它锁。读写锁允许多个线程可以同时读取(只读)共享变量。读写锁是分为读锁和写锁两种角色的,读线程在访问共享变量的时候必须持有相应读写锁的读锁,而且读锁是共享的、多个线程可以共同持有的;写锁是排他的,以一个线程在持有写锁的时候,其他线程无法获得相应锁的写锁或读锁。总之,读写锁通过读写锁的分离从而提高了并发性。
ReadWriteLock接口是对读写锁的抽象,其默认的实现类是ReentrantReadWriteLock。ReadWriteLock定义了两个方法readLock()和writeLock(),分别用于返回相应读写锁实例的读锁和写锁。这两个方法的返回值类型都是Lock。
关于ReentrantReadWriteLock实现,这里给出一个链接参看:
[ReentrantReadWriteLock详解]https://www.cnblogs.com/xiaoxi/p/9140541.html
读写锁主要用于读线程持有锁的时间比较长的情景下。
非互斥同步指的是不同的线程不对共享资源进行独占,不同的线程都可以访问共享资源,只不过当多个线程同时对一个共享变量进行修改或删除时,只有一个线程的操作能成功其他的都会失败。
Java中的原子类分为6种,分别有:
直接使用Java中的原子类进行操作即可在并发情况下保证变量的线程安全,原子类相较于锁粒度更小,性能更高。原子类也是基于CAS算法来实现的,其都包括compareAndSet()方法即为先比较当前值是否等于预期的值然后进行数据的修改从而保证了变量的原子性。
需要注意的是累加器LongAdder是Java8开始引入的,相较于AtomicLong,由于LongAdder在每个线程操作的过程中并不会实时的进行数据同步(由于上文所提到的JMM,AtomicLong会实时的进行多个线程之间的数据通信),所以效率更高。而LongAccumulator扩展了LongAdder使得原子变量不仅只能进行累加操作也可以进行其他指定公式的计算
Java中并发容器由来已久,当然并发容器的种类也非常多。但是其中一部分诸如Vector、Hashtable、Collections.synchronizedList()、Collections.synchronizedMap()等底层是基于synchronized来实现的并发同步,效率会比较低,所以即使这些容器可以保证线程安全也不再使用。与之相替代的就是下面的几种并发容器类,由于并发容器在实现上也有许多可学习之处,所以这里不再在一文中介绍而是会初步引入,并放上我认为比较不错的几个博客链接,这样可以更好的深入理解。
多个线程往HashMap中同时进行put(),如果有几个线程计算出的键的散列值相同,那么就会出现key丢失的情况,同样的,如果此时HashMap容量不够,多个线层同时扩容,也会只保留一个扩容后的Map,从而导致数据丢失。而ConcurrentHashMap则在底层数据结构的实现上与HashMap又有所区别,避免了HashMap会产生的问题。
关于ConcurrentHashMap的数据结构可以参看:
[ConcurrentHashMap的数据结构]https://blog.csdn.net/weixin_44460333/article/details/86770169#commentBox
为了保证List的线程安全,又要避免因使用Vector、Collections.synchronized等而产生的锁粒度过大而造成效率降低的问题,CopyOnWriteArrayList、CopyOnWriteArraySet应运而生,CopyOnWriteArrayList和CopyOnWriteArraySet在实现原理上大体一致,这里只给出CopyOnWriteArrayList的介绍.
关于CopyOnWriteArrayList的数据结构可以参看:
[CopyOnWriteArrayList的数据结构]https://www.cnblogs.com/chengxiao/p/6881974.html
BlockingQueue很好的解决了多线程中,如何高效安全“传输”数据的问题。通过这些高效并且线程安全的队列类,为我们快速搭建高质量的多线程程序带来极大的便利。在Java中,BlockingQueue是一个接口,它的实现类有ArrayBlockingQueue、DelayQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等,这些阻塞队列的实现在Java并发编程中经常要用到,其中最常用的就是ArrayBlockingQueue和LinkedBlockingQueue
关于BlockingQueue可以参看:
[BlockingQueue相关]https://segmentfault.com/a/1190000016296278
关于ArrayBlockingQueue可以参看:
[ArrayBlockingQueue相关]https://blog.csdn.net/u014799292/article/details/90167096
关于LinkedBlockingQueue可以参看:
[LinkedBlockingQueue相关]https://blog.csdn.net/tonywu1992/article/details/83419448
ConcurrentLinkedQueue是一个基于链接节点的非阻塞无界线程安全队列。
关于ConcurrentLinkedQueue的数据结构可以参看:
[ConcurrentLinkedQueue的数据结构]https://blog.csdn.net/qq_38293564/article/details/80798310#commentBox
ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。
ThreadLocal采用的是上述策略中的第一种设计思想——采用线程的特有对象.采用线程的特有对象,我们可以保障每一个线程都具有各自的实例,同一个对象不会被多个线程共享,ThreadLocal是维护线程封闭性的一种更加规范的方法,这个类能使线程中的某个值与保存值的对象关联起来,从而保证了线程特有对象的固有线程安全性。
ThreadLocal类相当于线程访问其线程特有对象的代理,即各个线程通过这个对象可以创建并访问各自的线程特有对象,泛型T指定了相应线程持有对象的类型。一个线程可以使用不同的ThreadLocal实例来创建并访问其不同的线程持有对象。多个线程使用同一个ThreadLocal实例所访问到的对象时类型T的不同实例。代理的关系图如下:
ThreadLocal提供了get和set等访问接口或方法,这些方法为每一个使用该变量的线程都存有一份独立的副本,因此get总是能返回由当前执行线程在调用set时设置的最新值。其主要使用的方法如下:
public T get(): 获取与当前线程中ThreadLocal实例关联的线程特有对象。
public void set(T value):重新关联当前线程中ThreadLocal实例所对应的线程特有对象。
protected T initValue():如果没有调用set(),在初始化threadlocal对象的时候,该方法的返回值就是当前线程中与ThreadLocal实例关联的线程特有对象。
public void remove():删除当前线程中ThreadLocal和线程特有对象的关系。
那么ThreadLocal底层是如何实现Thread持有自己的线程特有对象的?查看set()方法的源代码:
可以看到,当我们调用threadlocal的set方法来保存当前线程的特有对象时,threadlocal会取出当前线程关联的threadlocalmap对象,然后调用ThreadLocalMap对象的set方法来进行当前给定值的保存。
每一个Thread都会维护一个ThreadLocalMap对象,ThreadLocalMap是一个类似Map的数据结构,但是它没有实现任何Map的相关接口。ThreadLocalMap是一个Entry数组,每一个Entry对象都是一个"key-value"结构,而且Entry对象的key永远都是ThreadLocal对象。当我们调用ThreadLocal的set方法时,实际上就是以当前ThreadLocal对象本身作为key,放入到了ThreadLocalMap中。
可能发生内存泄漏:
通过查看Entry结构可知,Entry属于WeakReference类型,因此Entry不会阻止被引用的ThreadLocal实例被垃圾回收。当一个ThreadLocal实例没有对其可达的强引用时,这个实例就可以被垃圾回收,即其所在的Entry的key会被置为null,但是如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,从而发生内存泄露。
解决内存泄漏的最有效方法就是,在使用完ThreadLocal之后,要注意调用threadlocal的remove()方法释放内存。
传统的Runnable来实现任务有两大缺陷,一个是Runnable中的run()没有返回值,另一个是Runnable中的run()无法抛出异常。为了解决上述问题,Callable应运而生,而Future是为了更好的操作Callable实现业务逻辑而诞生的。
我们可以用Future.get来获取Callable接口返回的执行结果,还可以通过Future.isDone()来判断任务是否已经执行完了以及取消这个任务,限时获取任务的结果等等。
线程池提供了复用线程的能力,如果不使用线程池,那么每个任务都会新开一个线程,上文基石中也已经提到Java代码中的线程数量对应于操作系统的线程数量,这样对于线程的创建和销毁都会带来很大的开销,此外系统可创建的线程数量是有限的,使用线程池可以有效避免OOM等异常。
线程池的创建一般借助ThreadPoolExecutor
这个类,其中有5个参数比较关键,以下说明:
corePoolSize、maxPoolSize、workQueue
:线程池中默认存在的线程数量是corePoolSize,当任务多于corePoolSize时,新来的任务会首先存储在任务存储队列workQueue
中,当任务数量超出了任务存储队列的最大长度,线程池才会扩大其中的线程数量直到maxPoolSize
,当任务数量超出maxPoolSize
,线程池执行定义的拒绝策略handler
。
workQueue的三种常用类型:
1.SyncbronousQueue:最简单的直接交换队列,这队列长度为0不能存储新的任务,适用与任务不太多的场景,此外由于队列不能存储任务线程池很容易创建新的线程,所以maxPoolSize要设置的大一点,但是如果设置的maxPoolSize过大,线程创建的过多而不能得到调度从而产生堆积,就会引发OOM。Executors.newCachedThreadPool()、Executors.newScheduledThreadPool()
即为这种类型,其中Executors.newCachedThreadPool()的maxPoolSize这里设置的为Integer.MAX_VALUE,corePoolSize默认为0,keepAliveTime为60s
2.LinkedBlockingQueue:无解队列,这个相较于第一种队列属于另一个极端,可以存储任意数量的任务。此类队列可以存储较多数量的任务并且此时maxPoolSize会失效,但是此时也要注意任务过多时会产生堆积出现OOM。Executors.newFixedThreadPool()、Executors.newSingleThreadExecutor()
即为这种类型
3.ArrayBlockingQueue:有界队列,可以设置队列长度,此时maxPoolSize有效
keepAliveTime
:如果线程池当前的线程数量多余corePoolSize
,那么当多余线程的空闲时间超过keepAliveTime
时,它们将被回收。
ThreadFactory
:线程池中新创建的线程是由ThreadFactory
创建的,默认使用Executors.defaultThreadFactory()
。
线程池应该手动创建,其中:
当任务属于CPU密集型时,线程池中的线程数量应该设置为CPU核心数的1-2倍;当任务属于资源密集型时,线程池中的线程数量一般设置为cpu核心数的很多倍,计算方法一般为num=CPU核心数*(1+平均等待时间/平均工作时间)
线程池停止:
shutdown()
:调用此方法后,线程池并不会立刻停止而是拒绝接受新的任务并等待线程池中已在执行的线程任务和队列中的任务执行完毕
shutdownNow()
:调用此方法后,线程池通过调用terminated()方法来终止正在执行的线程同时将队列中未被调度的任务以集合的形式返回。
到此为止,本文要梳理的Java并发相关也告一段落,之所以如此说是因为Java并发相关确实是值得深入探究的一个领域,本文的定位是基于Java来梳理并发相关的那些事儿,尽可能通过一篇文章来归纳出Java并发中应该掌握的知识点。
本文仍然有很多不足之处,比如文中没有介绍Java的并发工具类诸如CountdownLatch、Semaphore等,而关于ReentrantLock这种重要的锁的实现原理AQS本文也没有介绍,希望在之后的文章中能对本文略过的点进行深入的归纳总结。