JDK8 JUC整理
Posted roykingw
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK8 JUC整理相关的知识,希望对你有一定的参考价值。
文章目录
一、内容与特点
JUC主要是指JDK8中java.util.concurrent里提供的一系列线程并发工具,但是线程并发的问题远不止几个工具这么简单。要学习工具使用,更要能深入理解工具的原理以及处理线程并发问题的思路。
新手学技术、老手学体系,高手学格局。
二、理解线程
1、线程与进程
进程是操作系统中分配资源的最小单位,线程是CPU调度运行的最小单位。同一个进程下分配多个线程,这些线程可以共享进程内的资源。
现在的CPU都是有多个核心独立运行的,所以使用多线程能够更多的使用CPU的性能。并且CPU的计算速度非常非常快,比我们程序的执行速度快很多。多线程情况下,CPU可以在多个线程之间轮转切换执行,也能进一步提高CPU的使用率。
2、为什么线程不是越多越好?
1:线程执行需要消耗CPU资源,即使线程休眠,依然会要消耗CPU资源。线程过多,就会提高CPU的使用率,加大CPU的负担。
2:CPU运行过程中会在线程之间不断切换,切换过程中,需要不断的切换线程上下文。切换过程中的资源消耗不大,但是如果线程过多,这个消耗就会更大,CPU的使用率反而降低了。
3:经常听说一句话:频繁的开启线程,需要分配线程ID,并将相关上下文保存下来。在线程切换过程中也需要进行上下文切换,还原线程运行的环境,这样会消耗系统资源。
有个简单的比喻,CPU就好比一个饭店的一批食客(多个CPU核心),他们只负责吃东西(计算数据),饭店(操作系统)给他们上什么他就吃什么。而线程就好比是服务员,负责给CPU上菜(分配计算资源)。这个食客吃东西非常快,合理的增加服务员数量,确实可以提高饭店的运行效率。但是如果服务员太多,就需要增加非常多的碗碟,这一批食客也需要在一大堆的碗碟当中去分配食物,反而浪费了非常多的吃饭的时间,整体效率自然就降低了。
所以线程并不是越多越好,需要合理的规划线程数量。通常在Java中,可以通过Runtime.getRuntime().availableProcessors();获取当前机器的CPU核心数。而在对程序进行线程规划时,对于数据计算比较多的进程,称为CPU密集型。这类进程相当于小碗菜,菜种类多和分量少,临时的中间变量也会非常多。规划的线程数最好和CPU核心数一样,这就相当于在饭店中合理的减少碗碟的数量。而对于数据读取比较多的进程,称为IO密集型。这类进程相当于大锅菜,菜的种类不多但是分量很大,临时的中间变量会比较少。这时规划的线程数可以相对多一点,达到CPU核心数的两倍都是可以接受的,这就相当于大碗传菜,碗碟相对不会太多,增加服务员更能提高执行效率。
同时,还可以使用一些技术如池化技术来进一步提高线程的使用率。这就相当于调整服务员的排班,让服务员的工作时间更紧凑,工作效率更高,同时也能减少服务员的个数。
3、Java中开启线程的方式
Runnable:没有返回值的线程任务。
new Thread(()->{},'Thread1').start()。
java可以开启线程吗? java开启线程是通过调用native本地方法,调用JVM的C/C++代码来启动线程。
另外,还有一种类似的异步任务Callable经常拿来跟Runnable比较。Callable是一种有返回值的任务。
FutureTask<String> futureTask = new FutureTask<>(()->{return "callable"});
futureTask.run();
futureTask.get();
但是futureTask实际上是在run()方法阻塞执行的。
public class CallAbleDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException, TimeoutException {
final FutureTask<String> futureTask = new FutureTask<>(() -> {
Thread.sleep(10000);
return "call";
});
new Thread(()->{futureTask.run();}).start();
try{
System.out.println(futureTask.get(5, TimeUnit.SECONDS));
}catch (TimeoutException e){
System.out.println(e.getMessage());
System.out.println(futureTask.get(6, TimeUnit.SECONDS));
}
}
}
三、理解线程安全问题
1、什么是线程安全问题?
多个线程操作同一个资源就会造成线程安全问题。
public class SaleTicketDemo1 {
public int ticket = 100 ;
public void saleTicket(){
ticket --;
System.out.println(Thread.currentThread().getName()+"; ticket = "+ ticket);
}
public static void main(String[] args) {
final SaleTicketDemo1 saler = new SaleTicketDemo1();
for (int i = 0; i < 10; i++) {
new Thread(()->{saler.saleTicket();},""+i).start();
}
}
}
会发现ticket减少的顺序是乱的,并且会有重复的问题。
线程安全问题的根源在于多个线程同时争抢同一个内存资源,造成线程工作的结果未能及时通知给其他线程。
2、如何解决线程安全问题?
解决线程安全问题的方法就是要规范线程对资源的操作顺序。主要有两种,一种是针对资源加锁,让线程有序的竞争对资源的操作。另一种是针对线程,让线程进行有序排队。
1、针对资源加锁:
加锁有两种常用的方法,一种是使用Java提供的Synchronized关键字自动加锁。另一种是使用JUC包中的Lock对象,手动加锁。
对于Synchronized关键字:使用方法比较简单,可以在后面加一个作为锁使用的对象。任何的java对象都可以作为锁来使用。持有该对象的线程才能够去执行后面受保护的代码。
synchroniezd(obj){ protected code}
另外Synchronized关键字,可以直接加在方法描述上。如果是static方法,表示对Class加锁,如果是普通方法,表示对该对象加锁。例如对上面示例中的saleTicket()方法前面加上一个synchronized关键字,整个执行结果就会是正确且有序的。
关于锁的本质其实只是在对象的头部markdown部分加一个描述符。根据资源竞争激烈程度分为无锁、偏向锁、轻量级锁、重量级锁。 参见 https://blog.csdn.net/roykingw/article/details/107389045
对于Lock锁:是java.util.concurrent包中提供的一个顶层接口,由此提供了一系列的同步锁工具。最为常用的实现类就是ReentrantLock,可重入锁。
public class SaleTicketDemo1 {
private Lock lock = new ReentrantLock();
public int ticket = 100 ;
public void saleTicket(){
lock.lock();
try{
ticket --;
System.out.println(Thread.currentThread().getName()+"; ticket = "+ ticket);
}finally {
lock.unlock();
}
}
.....
}
关于Lock锁,在JDK中的实现类大致如下:
其中Lock接口提供了基础的上锁lock与unlock操作。他的一个重要子类ReentrantLock是一个可重入锁的实现。可重入的概念从字面上就能看到,表示可以对这个锁对象多次上锁。需要注意的是多次上锁也需要多次解锁。
另外,在ReentrantLock当中,可以通过扩展出多个Condition来扩展锁的使用范围。
关于ReentrantLock,是JUC当中很重要的一个实现类。内部使用AQS框架实现了公平锁与非公平锁。公平锁表示排队的线程不能插队。在构造ReentrantLock时,就可以传入一个boolean类型的参数,默认是false表示是非公平锁。传入true表示是公平锁。在后面AQS部分还会回过头来分析ReentrantLock。
然后还一个ReadWriteLock接口,提供了读写锁分离的实现。提供一个readLock()方法获取读锁,还提供一个writeLock()方法获取写锁。 读写锁可以分开操作。在提供的ReentrantReadWriteLock实现类中,提供了读写锁的具体实现。
主要的实现思想就是,
- 对同一个锁对象,写锁是独占锁,同一时刻只能一个线程写,其他线程需要排队。
- 而读锁是共享锁,同一时刻可以有多个线程同时读。
- 读锁与写锁不能同时存在。
2、让线程合理的排队:
public class SaleTicketDemo2 {
public int ticket = 100;
public void saleTicket(){
if(ticket >0) {
this.ticket--;
System.out.println(Thread.currentThread().getName() + "; ticket = " + ticket);
}
}
public static void main(String[] args) throws InterruptedException {
SaleTicketDemo2 demo = new SaleTicketDemo2();
Semaphore s1 = new Semaphore(1);
Semaphore s2 = new Semaphore(1);
s1.acquire();
new Thread(()->{
while(demo.ticket >0){
try{
s1.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
demo.saleTicket();
s2.release();
}
},"saler1").start();
new Thread(()->{
while(demo.ticket >0){
try{
s2.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
demo.saleTicket();
s1.release();
}
},"saler2").start();
}
}
没有使用锁,让两个线程交替进行。
public class SaleTicketDemo3 {
public volatile int ticket = 20;
public void saleTicket(){
if(ticket >0) {
this.ticket--;
System.out.println(Thread.currentThread().getName() + "; ticket = " + ticket);
}
}
public static int number = 0 ;
public static void main(String[] args) {
SaleTicketDemo3 demo = new SaleTicketDemo3();
for (int i = 0; i < 10; i++) {
int current = i;
new Thread(()->{
while(demo.ticket >0){
if(number == current){
demo.saleTicket();
number++;
if(number==10){
number = 0 ;
}
}
}
},"saler"+i).start();
}
}
}
实现了十个线程依次有序售票的效果。这个示例中要注意对ticket的定义,volatile关键字是很重要的,没有volatile关键字的话,整个程序大概率不能正常结束。volatile关键字是干什么用的?记得吗?别急,后面会再梳理这个关键字。
有人可能会觉得这种让线程排序的方式会将并行转为串行,会影响效率。其实这问题基本上是不存在的。因为对于每一个CPU核心来说,他处理问题是通过时间片轮换的方式进行的,本质上来说只能是串行的。只是CPU执行速度非常快,才产生了并行的效果。所以如果让线程合理的进行排序,对效率影响不会很大。
当然,让线程乖乖的排序并不容易,也正是这样才体现出Doug Lea大师带来的JUC的强大。实际上,JUC中很大一部分的功能就是通过对线程进行合理的排序来解决的。在后面的部分会再做深入的介绍。
四、JDK集合工具并发思路
在JDK提供的集合工具中,针对线程安全其实做了非常多的设计。常用的思路有以下几种。
1、加锁
即便是在JDK源码中,加锁依然是解决线程安全问题做常用的方式。
比如常用的ArrayList,在多线程下是不安全的。例如
public class ArrayListUnsafeDemo {
public static List arrayList = new ArrayList();
public static void main(String[] args) {
Thread []threadArray = new Thread[1000];
//启动多线程,操作同一个ArrayList
for(int i=0;i<threadArray.length;i++){
threadArray[i] = new Thread(()->{
ArrayListUnsafeDemo.arrayList.add(Thread.currentThread().getName());
});
threadArray[i].start();
}
//等待所有线程结束
for(int i=0;i<threadArray.length;i++){
try {
threadArray[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//打印ArrayList结果
for(int i=0;i<arrayList.size();i++){
System.out.println(arrayList.get(i));
}
}
}
在执行这段代码的过程中,会出现三种可能的线程安全问题,1、输出值为null,2、某些线程没有输出值,3、数组越界异常。
然后下面这个示例会抛出java.util.ConcurrentModificationException异常
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 1; i <= 30; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}).start();
}
}
而他抛出异常的地方在这个方法
private void checkForComodification() {
if (ArrayList.this.modCount != this.modCount)
throw new ConcurrentModificationException();
}
其实在每次对数据做操作时都会同步这两个modCount,而之所以会出现不相等的情况就是因为在多线程情况下,会出现同步代码未执行的情况。
而JDK给出的一个线程安全的实现是Vector。Vector中一些主要的修改数据的方式都是加了sychronized关键字,以这种方式来实现线程安全,会将多线程的并行运行改为串行运行,会降低应用的执行效率,所以官方也是不推荐使用Vector。
2、CAS
关于CAS,CAS在底层会通过线程自旋的方式进行资源竞争,但是在Java中,CAS操作只是Unsafe类中的一个同步方法而已。在Java中通常会通过for(;;)这样的操作来保证线程不停止。但是本质上,这只是一个循环而已,跟线程的自旋不是一个概念。另外,CAS操作本身其实并不能保证操作的原子性,Java中CAS操作能保证线程安全很大程度上是因为底层JVM的支持。
例如对于JDK的Map接口,关于线程安全问题,主要对比HashMap、HashTable和ConcurrentHashMap。
对于Map最常用的实现类就是HashMap。HashMap通过一个Node<K,V>[] table数组来维护数据HashEntry,而每一个Node会有一个next属性指向他后面的Node。每次put数据,会将key进行hash计算,获取hashcode后,以取模的方式分配到对应的一个Node后面。而如果发生Hash碰撞,就会将当前Node添加到对应Hash位置的Node的后面,形成一个链表结构。而如果某一个Node后面的链表长度大于8(不包括8,由一个内部参数TREEIFY_THRESHOLD=8定义),会将整个链表结构转换成红黑树结构,以提高查询数量。进行remove操作删除数据时,如果对应的红黑树节点个数小于等于6(包括6,由一个内部参数UNTREEIFY_THRESHOLD=6定义),会将整个红黑树结构再退化成链表结构。 HashMap中有一个参数DEFAULT_LOAD_FACTOR,加载因子,默认是0.75。这表示当Map中的数据容量超过最大容量的75%之后,HashMap就会进行扩容,每次扩容按照2的指数倍进行数量扩容。
但是HashMap是线程不完全的,他的线程不安全主要体现在扩容的过程当中。在多线程情况下,HashMap在容量扩容的过程中会有几率形成环形引用,会导致get获取数据时出现死循环。
JDK并没有从根本上尝试去解决HashMap的线程安全问题,因为要解决线程安全问题,就必然会降低HashMap的运行效率。JDK通过提供另外的Map实现类来解决HahMap的线程安全问题。
HashTable实现了HashMap的线程安全操作,但是他的实现方式是对所有的关键操作加sychronized关键字上锁来保证的。这种方式每次操作都是对当前对象上锁,将多线程的并行操作转换成了串行操作。但是这样一方面会造成执行效率低下,另外也无法完全解决多线程的安全问题。
Map<String,Object> map = new HashTable<>();
if(map.containsKey("aaa")){ // 锁当前对象
map.put("aaa",value); //
}
例如对于上面这种常用的符合操作,map.containsKey和map.put两个操作是两个不同的锁步骤,在多线程情况下,会分开进行两次不同的锁竞争过程,整体上依然会有线程安全问题。
HashTable并不靠谱,所以JDK又提供了另外一个线程安全的实现版本ConcurrentHashMap。
在1.7版本及以前,ConncurrentHashMap通过Segment实现分段加锁。Segment是ReentrantLock的一个子类。在内部会维护一个Segment数组,一个Segment对一部分的HashEntry进行加锁保护。也就是说在ConncurrentHashMap中会持有一个锁数组,每次只针对一部分的数据HashEntry进行加锁,这样相比HashTable就能够提供更细粒度的锁,提高并发性能。
而在1.8版本之后,ConncurrentHashMap去掉了分段锁的支持,改为使用CAS+synchronized的方式实现线程安全。并对Node对象的当前值属性val和下一个Node指针next增加volatile关键字保证多线程可见性。整体并发性能得到进一步提升。例如在ConcurrentHashMap中,put操作添加一个不存在的元素时,会使用cas操作保证只有一个线程能够添加新成功。(这个casTabAt方法就是使用的cas操作。)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
而在后面对已有的值进行更新时,则会使用synchronized关键字来保证线程安全性。
3、CopyOnWrite
CopyOnWrite写时复制也是JDK中解决线程安全问题的一种机制。这种思想就是在发现对数据的写操作反生时,就会为每个调用者创建一个专有副本。每个调用者在写数据时会先将数据在副本上完成修改。在修改完成后再整体同步到原始资源中。而对于所有读数据的调用者,只需要关注同一份数据,而不用考虑其他调用者在修改数据过程中产生的中间状态。在JDK8中提供了CopyOnWriteArrayList 和CopyOnWriteArraySet两种实现。
例如CopyOnWriteArrayList的add方法就会在写出过程中创建副本。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
这里锁的意义是用来保证副本数据写回的过程中不会发生混乱。
使用CopyOnWrite后,他的读数据操作就可以是一个无锁的普通操作。因为他们读的始终是同一份没有中间状态的数据。
public E get(int index) {
return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
五、JDK并发工具
JDK中提供了几个并发工具,能够很好的简化对线程进行各种排队逻辑的编程模型。常用的有三个,CountDownLatch,CyclicBarrier,Semaphore。另外还有一个用得不是很多的SynchronousQueue。
1、CountDownLatch
可以理解为赛跑,大家都在起跑线上等着,进行5,4,3,2,1倒数,倒数到0时,所有线程就一起开跑。这在我们需要模拟高并发操作时非常有用。我们平常的手段,例如多线程、for循环、线程休眠唤醒等,都很难模拟到CPU级别的并发操作。而使用CountDownLatch就非常容易实现。
//使用CountDownLatch模拟高并发
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDown = new CountDownLatch(1);
//5个线程启动时间不同
for (int i = 0; i < 5; i++)以上是关于JDK8 JUC整理的主要内容,如果未能解决你的问题,请参考以下文章