0、什么是JUC
JUC:就是我们Java原生的并发包,和一些常用的工具类!
学完之后,很多知识,但是不知道怎么去用!每学习一个知识点,学完之后,可以替换工作中用到的代码!
1、并发基础知识
进程与线程
-
进程
就是正在运行的程序,操作系统调用的最小单位,是系统进行资源分配(cpu、内存)和调度的独立单位。
-
线程
因为进程的创建、销毁、切换产生大量的时间和空间的开销,进程的数量不能太多,而线程是比进程更小的能独立运行的基本单位,是进程的一个子任务,是CPU调度的最小单位。线程可以减少程序(进程)并发执行时的时间和空间开销,使得操作系统具有更好的并发性。
线程基本不拥有系统资源,只有一些运行时必不可少的资源,运行完后就会释放,比如程序计数器、寄存器和栈,进程则占有堆、栈。线程,Java默认有两个线程 main 跟GC。Java是没有权限开线程的,无法操作硬件,都是调用的 native 的 start0 方法 由 C++ 实现
Java程序运行后至少有两个线程:GC垃圾回收和Main主线程。
进程相当于一个空盒,它只提供资源装载的空间,具体的调度并不是由进程来完成的,而是由线程来完成的。一个java程序从main线程开始,进程启动,为整个程序提供各种资源,而此时将启动一个线程,这个线程就是主线程,它将调度资源,进行具体的操作。Thread、Runnable开启的线程是主线程下的子线程,是父子关系,此时该java程序即为多线程的,这些线程共同进行资源的调度和执行
操作系统中,CPU是采用时间片轮转的方式运行进程的:CPU为每个进程分配一个时间段。如果时间片结束时进程还在运行,则暂停这个进程的运行,并且CPU分配给另一个进程,这个叫做进程的上下文切换。注意:如果进程在时间片结束前被阻塞或结束,则CPU立即进行切换,不用等待时间片用完。
进程+CPU时间片轮转的方式体现了操作系统的并发,即同一时间段可以执行多个任务,一个进程执行一个任务。但是一个进程在一段时间只能做一件事情,如果一个进程有多个子任务,只能逐个执行,很影响效率。于是就诞生线程的概念,每个线程负责一个单独的子任务,CPU时间片在线程粒度轮流切换,并发执行多个子任务,使操作系统具有更好的并发性。
下面转载阮一峰老师的解释,加深对进程和线程的理解,原文https://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html
计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个CPU一次只能运行一个任务。
进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态。
一个车间里,可以有很多工人。他们协同完成一个任务。
线程就好比车间里的工人。一个进程可以包括多个线程
车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存。
可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫”互斥锁”(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。
还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。
这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做”信号量”(Semaphore),用来保证多个线程不会互相冲突。
不难看出,互斥锁mutex是信号量semaphore的一种特殊情况(n=1时)。也就是说,完全可以用后者替代前者。但是,因为mutex较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计。
操作系统的设计,因此可以归结为三点:
- 以多进程形式,允许多个任务同时运行;
- 以多线程形式,允许单个任务分成不同的部分运行;
- 提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源。
Java 不能创建线程
Java 不能创建线程,从new Thread().start()查看源码就可以看到它底层调用的是一个native start0()的本地方法来创建线程的,一个native 方法就是一个java调用非java代码的接口,该方法由C++实现。
public synchronized void start() {
/**
* This method is not invoked for the main method thread or "system"
* group threads created/set up by the VM. Any new functionality added
* to this method in the future may have to also be added to the VM.
*
* A zero status value corresponds to state "NEW".
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();
/* Notify the group that this thread is about to be started
* so that it can be added to the group's list of threads
* and the group's unstarted count can be decremented. */
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
private native void start0();
并发与并行
-
并发:多个线程操作同一个资源,单核CPU极速的切换运行多个任务交替执行的过程
-
并行:多个线程同时执行,只有在多核CPU下才能完成
所以我们使用多线程或者并发编程的目的:提高效率,让CPU一直工作,达到最高处理性能!
并发编程的出发点:充分利用CPU计算资源,多线程并不是一定比单线程快,要不为什么Redis6.0版本的核心操作指令仍然是单线程呢?对吧!
线程的6种状态
查看Thread.java的源码,线程有6种状态
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called <tt>Object.wait()</tt>
* on an object is waiting for another thread to call
* <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
* that object. A thread that has called <tt>Thread.join()</tt>
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
我们一般会加一个RUNNING状态,是由CPU决定线程是处于RUNNABLE还是RUNNING(获取到cpu时间片)
并发可见性
可见性 的定义是:一个线程对共享变量的修改,另外一个线程能够立刻看到。
首先内存是不直接与cpu打交道的,而是通过高速缓存与cpu打交道
cpu <——> 高速缓存 <———> 内存
可见性问题就是由于cpu缓存不一致,在并发编程时表现出来的问题,而其中的主要发生的场景有下面三种:
-
线程交叉执行
线程交叉执行多数情况是由于线程切换导致的,例如下图中的线程A在执行过程中切换到线程B执行完成后,再切换回线程A执行剩下的操作;此时线程B对变量的修改不能对线程A立即可见,这就导致了计算结果和理想结果不一致的情况。
-
重排序结合线程交叉执行
例如下面这段代码
int a = 0; // 行1 int b = 0; // 行2 a = b + 10; // 行3 b = a + 9; // 行4
如果行1和行2在编译的时候改变顺序,执行结果不会受到影响;
如果将行3和行4在编译的时候交换顺序,执行结果就会受到影响,因为b的值得不到预期的19,怎么像cpu指令重排。
由图知:由于编译时改变了执行顺序,导致结果不一致;而两个线程的交叉执行又导致线程改变后的结果也不是预期值,简直雪上加霜!
-
共享变量更新后的值没有在工作内存及主内存间及时更新
因为主线程对共享变量的修改没有及时更新,子线程中不能立即得到最新值,导致程序不能按照预期结果执行。
package com.itquan.service.share.resources.controller; import java.time.LocalDateTime; public class VisibilityDemo { // 状态标识flag private static boolean flag = true; public static void main(String[] args) throws InterruptedException { System.out.println(LocalDateTime.now() + "主线程启动计数子线程"); new CountThread().start(); Thread.sleep(1000); // 设置flag为false,使上面启动的子线程跳出while循环,结束运行 VisibilityDemo.flag = false; System.out.println(LocalDateTime.now() + "主线程将状态标识flag被置为false了"); } static class CountThread extends Thread { @Override public void run() { System.out.println(LocalDateTime.now() + "计数子线程start计数"); int i = 0; while (VisibilityDemo.flag) { i++; // 不是原子性操作 } System.out.println(LocalDateTime.now() + "计数子线程end计数,运行结束:i的值是" + i); } } }
执行结果:
从结果可见,主线程对flag的修改,对计数子线程没有立即可见,所以导致了计数子线程久久不能跳出while循环,结束子线程。
可见性问题的解决
1、volatile 保证可见性,不保证原子性
volatile
关键字能保证可见性,但也只能保证可见性,在此处就能保证flag的修改能立即被计数子线程获取到。volatile关键字要求被修改之后的变量要求立即更新到主内存(堆heap,共享可见),每次使用前必须从主内存处进行读取
// 状态标识flag
private static volatile boolean flag = true;
2、Atomic相关类:原子类保证可见性和原子性
// 状态标识flag,原子类
private static AtomicBoolean flag = new AtomicBoolean(true);
不过值得注意的一点是,此时原子类相关的方法设置新值和得到值的放的是有点变化,如下:
// 设置flag的值
VisibilityDemo.flag.set(false);
// 获取flag的值
VisibilityDemo.flag.get()
3、Lock: 保证可见性和原子性
此处我们使用的是Java常见的synchronized关键字。
此时纠正上面例子出现的问题,只需在为计数操作i++
添加synchronized
关键字修饰
synchronized (this) {
i++;
}
也可以使用ReentrantLock
并发原子性
一个操作不可再分,即为原子性。而在并发编程的环境中,原子性的含义就是只要该线程开始执行这一系列操作,要么全部执行,要么全部未执行,不允许存在执行一半的情况。
我们试着从数据库事务和并发编程两个方面来进行对比:
1、在数据库中
原子性概念是这样子的:事务被当做一个不可分割的整体,包含在其中的操作要么全部执行,要么全部不执行。且事务在执行过程中如果发生错误,会被回滚到事务开始前的状态,就像这个事务没有执行一样。(也就是说:事务要么被执行,要么一个都没被执行)
2、在并发编程中
原子性概念是这样子的:
- 第一种理解:一个线程或进程在执行过程中,没有发生上下文切换。
- 上下文切换:指 CPU 从一个进程/线程切换到另外一个进程/线程(切换的前提就是获取 CPU 的使用权)。
- 第二种理解:我们把一个线程中的一个或多个操作(不可分割的整体),在 CPU 执行过程中不被中断的特性,称为原子性。(执行过程中,一旦发生中断,就会发生上下文切换)
从上文中对原子性的描述可以看出,并发编程和数据库两者之间的原子性概念有些相似:都是强调,一个原子操作不能被打断!!
而非原子操作用图片表示就是这样子的:
线程 A 在执行一会儿(还没有执行完成),就出 让 CPU 让线程 B 执行。这样的操作在操作系统中有很多,牺牲切换线程的极短耗时,来提高 CPU 的利用率,从而在整体上提高系统性能;操作系统的这种操作就被称为『时间片』切换。
共享变量的原子性问题解决
-
synchronized 关键字同步控制
-
Lock锁
-
原子类操作
JDK
提供了很多原子操作类来保证操作的原子性。比如最常见的基本类型:AtomicBoolean AtomicLong AtomicDouble AtomicInteger
底层使用的是CAS(compare and set)比较交换机制,这个机制保证了整个赋值操作是原子的不能被打断的,从而保证了最终结果的正确性。CAS也可以理解为自旋锁,和 synchronized 相比,原子操作类型相当于是从微观上保证原子性,而 synchronized 是从宏观上保证原子性。
但是,不要以为使用了线程安全类,你的所有代码就都是线程安全的!这总归都要从审查你代码的整体原子性出发。就比如下面的例子:
@NotThreadSafe public class UnsafeFactorizer implements Servlet { private final AtomicReference<BigInteger> lastNum = new AtomicReference<BigInteger>(); private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>(); @Override public void service(ServletRequest request, ServletResponse response){ BigInteger tmp = extractFromRequest(request); if (tmp.equals(lastNum.get())) { System.out.println(lastFactors.get()); } else { BigInteger[] factors = factor(tmp); lastNum.set(tmp); lastFactors.set(factors); System.out.println(factors); } } }
虽然它全部用了原子类来进行操作,但是各个操作之间不是原子性的。也就是说:比如线程 A 在执行
else
语句里的lastNum.set(tmp)
完后,也许其他线程执行了if
语句里的lastFactors.get()
方法,随后线程A
才继续执行lastFactors.set(factors)
方法更新factors
!这个逻辑过程中,线程安全问题就已经发生了。
它破坏了方法的
读取 A- 读取 B- 修改 A- 修改 B- 写入 A- 写入 B
这一整体过程,在写入 A 完成以后其他线程去执行了读取 B,就导致了读取到的 B 值不是写入后的 B 值。
最后
贴一段经常看到的原子性实例问题。
问:常听人说,在32位的机器上对long型变量进行加减操作存在并发隐患,到底是不是这样呢?
答:在32位的机器上对long型变量进行加减操作存在并发隐患的说法是正确的。
原因就是:线程切换带来的原子性问题。
非 volatile
类型的 long
和 double
型变量是 8 字节 64 位的,32 位机器读或写这个变量时得把人家咔嚓分成两个 32 位操作,可能一个线程读了某个值的高 32 位,低 32 位已经被另一个线程改了。所以官方推荐最好把 long\double
变量声明为volatile
或是同步加锁 synchronize
以避免并发问题。
并发有序性
操作系统为了提升性能,将 Java 语言转换成机器语言的时候,吩咐编译器对语句的执行顺序进行了一定的修改,以促使系统性能达到最优,这就是指令重排。所以在很多情况下,访问一个程序变量(对象实例字段,类静态字段和数组元素)可能会使用不同的顺序执行,而不是程序语义所指定的顺序执行,你写的代码并不是按照你的意思执行指的就是这个意思。
在单核时代,指令重排没有问题,但在多核时代,这种优化碰上线程切换就大大的增加了事故的出现几率!
也就是说,有序性 指的是在代码顺序结构中,我们可以直观的指定代码的执行顺序, 即从上到下按序执行。但编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,但最终结果看起来没什么变化(单核)。
有序性问题 指的是在多线程环境下(多核),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题。
场景:
如果一个线程写入值到字段 a,然后写入值到字段 b ,而且b的值不依赖于 a 的值,那么,处理器就能够自由的调整它们的执行顺序,而且缓冲区能够在 a 之前刷新b的值到主内存。此时就可能会出现有序性问题。
import java.time.LocalDateTime;
/**
* @author :mmzsblog
* @description:并发中的有序性问题
* @date :2020年2月26日 15:22:05
*/
public class OrderlyDemo {
static int value = 1;
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 199; i++) {
value = 1;
flag = false;
Thread thread1 = new DisplayThread();
Thread thread2 = new CountThread();
thread1.start();
thread2.start();
System.out.println("=========================================================");
Thread.sleep(6000);
}
}
static class DisplayThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " DisplayThread begin, time:" + LocalDateTime.now());
value = 1024;
System.out.println(Thread.currentThread().getName() + " change flag, time:" + LocalDateTime.now());
flag = true;
System.out.println(Thread.currentThread().getName() + " DisplayThread end, time:" + LocalDateTime.now());
}
}
static class CountThread extends Thread {
@Override
public void run() {
if (flag) {
System.out.println(Thread.currentThread().getName() + " value的值是:" + value + ", time:" + LocalDateTime.now());
System.out.println(Thread.currentThread().getName() + " CountThread flag is true, time:" + LocalDateTime.now());
} else {
System.out.println(Thread.currentThread().getName() + " value的值是:" + value + ", time:" + LocalDateTime.now());
System.out.println(Thread.currentThread().getName() + " CountThread flag is false, time:" + LocalDateTime.now());
}
}
}
}
运行结果
从打印的可以看出:在 DisplayThread
线程执行的时候肯定是发生了重排序,导致先为 flag
赋值,然后切换到 CountThread
线程,这才出现了打印的 value
值是1,falg
值是 true
的情况,再为 value
赋值;不过出现这种情况的原因就是这两个赋值语句之间没有联系,所以编译器在进行代码编译的时候就可能进行指令重排序。
解决有序性问题
1、volatile 关键字禁止指令重排
volatile
的底层是使用内存屏障来保证有序性的(让一个 CPU
缓存中的状态(变量)对其他 CPU
缓存可见的一种技术)。
volatile
变量有条规则是指对一个 volatile
变量的写操作, Happens-Before于后续对这个 volatile
变量的读操作。并且这个规则具有传递性,也就是说:
此时,我们定义变量 flag
时使用 volatile
关键字修饰,如:
private static volatile boolean flag = false;
也就是说,只要读取到 flag=true;
就能读取到 value=1024
;否则就是读取到flag=false;
和 value=1
的还没被修改过的初始状态;
但也有可能会出现线程切换带来的原子性问题,就是读取到 flag=false;
而value=1024
的情况;看过上一篇讲述原子性的文章的小伙伴,可能就立马明白了,这是线程切换导致的。
2、第二种方法,加锁
此处我们直接采用Java语言内置的关键字 synchronized
,为可能会重排序的部分加锁,让其在宏观上或者说执行结果上看起来没有发生重排序。代码修改也很简单,只需用 synchronized
关键字修饰 run
方法即可,代码如下
public synchronized void run() {
value = 1024;flag = true;
}
当然也可以使用 Lock
加锁,但 Lock
必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。这点在使用的时候一定要注意!
readWriteLock.writeLock().lock();
try {
value = 1024;
flag = true;
} finally {
readWriteLock.writeLock().unlock();
}
线程封闭
在《Java并发编程实战》中,是这样解释的,当我们访问共享的可变数据的时候,我们通常是需要使用同步的,而不使用同步的话,那么我们就不能共享收据,如果说仅仅是在单线程内访问数据的话,就不需要同步,这种技术被称之为线程封闭。
同步是为了保证可变数据共享的时候的安全,如果可变数据不需要被多个线程共享,也就是单线程内访问数据,那么就不需要同步,从而确保安全性和正确性
线程封闭有哪些类
1、ThreadLocal类
首先我们先说第一种,使用ThreadLocal,这个类能够使线程中的某个值和保存值进行关联,它提供了一系列的方法:ThreadLocal.get()、ThreadLocal.set()、ThreadLocal.initialValue(),这些方法都会使该变量在内存中保存一个副本,
而这三个方法总的来说,都是对共享变量的一个改变,不论是进行初始化,还是进行赋值和改变,都是对共享变量的修改。
怎么使用ThreadLocal类来维持线程的封闭呢?
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){
//初始化ThreadLocal对象connectionHolder的共享变量为Connection类型的对象
public Connection initialValue(){
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection(){
//使用ThreadLocal对象的get()方法获取其共享变量
return connectionHolder.get();
}
以上代码来自于《Java并发编程实战》,那么我们就来分析一下ThreadLocal这个类,它用于防止对可变的变量进行共享的时候出现不安全的操作所针对的,在单线程的程序中我们需要时刻保证数据库的连接,也就是我们只有这一个 Connection,而JDBC的链接对象它是不安全的,所以,我们把这个JDBC的连接保存在ThreadLocal中,让每个线程都拥有自己的连接。
这么说是不是就很好理解了,那么为什么ThreadLocal这个类能够保存线程局部变量的状态,使得每次访问此变量时都能获得实时的、正确的值呢?这个就得看源码了,阿粉在这里也给大家讲一点,大家有兴趣的也可以自己去看看源码里面是怎么写的。
public class ThreadLocal<T> {
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
}
ThreadLocalMap内创建了Entry数组,其构造行为和HashMap那真的是有点兄弟的意思了,而阿粉刚才也说了它的get,initialValue,和set方法都是为了对共享变量进行操作,就是在这里,当第一次调用get()方法时,会调用initialValue()方法,默认返回一个空值。所以在使用ThreadLocal时需要将其子类化并重写此方法,创建需要关联的变量,
而初始化的过程,就是建立新的ThreadLocalMap对象,将ThreadLocal对象与变量关联起来,而我们在Thread的方法中就能找到ThreadLocal和ThreadLocalMap关联的证明,这里不给大家去寻找了,大家去源码里面搜一下一定可以看到,位置在180行附近。
2、使用栈封闭来进行线程封闭
而在《Java并发编程实战》当中,说了一句话叫做栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals被封闭在方法中,不要使它们逸出!
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for (Animal a : animals) {
if (candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPair(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
我们可以看一下,numPairs这个局部的基本类型的变量,就是你不管干什么,你都无法去破坏这个numPairs,也无法去破坏栈的封闭性,因为你这个局部变量出不去,只是定义在这loadTheArk的程序中,外边的任何方法想搞事情,不好意思,完全不存在,所以,我们就明白这种栈的封闭是如何实现线程封闭的了。
3、Ad-hoc线程封闭
Ad-hoc的翻译是来自拉丁语,这个短语的意思是’特设的、特定目的的(地)、即席的、临时的、将就的、专案的’。这个短语通常用来形容一些特殊的、不能用于其它方面的的,为一个特定的问题、任务而专门设定的解决方案。这个词汇须与apriori区分。
其实这Ad-hoc线程封闭最简单的一句话,就是维护线程封闭性,让程序自己负责,就这么low,一个这么高大上的词汇,解释了半天意思就这么直白,但是还是得把他原来翻译的解释出来
Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象(例如,GUI应用程序中的可视化组件或数据模型等)的引用通常保存在公有变量中。这个只做了解。
总结
2、wait和sleep的区别
juc编程中,线程休眠要用TimeUnit实现
TimeUnit.SECONDS.sleep(3);
-
wait是Object类,sleep是Thread类
-
wait会释放锁,sleep不会(抱着锁睡觉)
-
用法不同
wait和notify一组,在线程通信时使用;
sleep一个单独方法,那里都可以使用
3、Lock锁
传统方式synchronized 同步关键字
package com.coding.demo01;
// 传统的 Synchronized
// Synchronized 方法 和 Synchronized 块
/*
* 我们的学习是基于企业级的开发进行的;
* 1、架构:高内聚,低耦合
* 2、套路:线程操作资源类,资源类是单独的
*/
public class Demo01 {
public static void main(String[] args) throws InterruptedException {
// 1、新建资源类
Ticket ticket = new Ticket();
// 2、线程操纵资源类
new Thread(new Runnable() {
public void run() {
for (int i = 1; i <=40; i++) {
ticket.saleTicket();
}
}
},"A").start();
new Thread(new Runnable() {
public void run() {
for (int i = 1; i <=40; i++) {
ticket.saleTicket();
}
}
},"B").start();
new Thread(new Runnable() {
public void run() {
for (int i = 1; i <=40; i++) {
ticket.saleTicket();
}
}
},"C").start();
}
}
// 单独的资源类,属性和方法!
// 这样才能实现复用!
class Ticket{
private int number = 30;
// 同步锁,厕所 =>close=>
// synchronized 这是一个关键字
public synchronized void saleTicket(){
if (number>0){
System.out.println(Thread.currentThread().getName() + "卖出第"+(number--)+"票,还剩:"+number);
}
}
}
Lock 锁
juc编程使用lock锁与lambda表达式,代码更简洁易懂
public class Demo02 {
public static void main(String[] args) {
// 1、新建资源类
Ticket2 ticket = new Ticket2();
// 2、线程操作资源类 , 所有的函数式接口都可以用 lambda表达式简化!
// lambda表达式 (参数)->{具体的代码}
new Thread(()->{for (int i = 1; i <= 40 ; i++) ticket.saleTicket();},"A").start();
new Thread(()->{for (int i = 1; i <= 40 ; i++) ticket.saleTicket();},"B").start();
new Thread(()->{for (int i = 1; i <= 40 ; i++) ticket.saleTicket();},"C").start();
}
}
// 依旧是一个资源类
class Ticket2{
// 使用Lock,它是一个对象
// ReentrantLock 可重入锁:回家:大门 (卧室门,厕所门...)
// ReentrantLock 默认是非公平锁!
// 非公平锁: 不公平 (插队,后面的线程可以插队)
// 公平锁: 公平(只能排队,后面的线程无法插队)
private Lock lock = new ReentrantLock();
private int number = 30;
public void saleTicket(){
lock.lock(); // 加锁
try {
// 业务代码
if (number>0){
System.out.println(Thread.currentThread().getName() + "卖出第"+(number--)+"票,还剩:"+number);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock(); // 解锁
}
}
}
执行结果:
Synchronized 和 Lock 的区别
-
Synchronized 是一个关键字,Lock是一个对象
-
Synchronized 无法尝试获取锁,Lock可以
-
Synchronized 会自动释放锁(A线程执行完毕,或者B线程异常,也会释放锁)
Lock锁是手动释放的,如果不释放会造成死锁
-
Synchronized 会让线程一直等待直到获取锁,Lock可以尝试获取锁,不会一直等待
-
Synchronized一定是非公平的,Lock是公平的锁,通过参数设置
-
Synchronized 适合代码量小的同步问题
Lock 适合代码量大的时候,可以实现精准控制
对synchronized关键字做一些总结:
synchronized关键字的作用域有二种:
-
是某个对象实例内,
synchronized aMethod(){}
可以防止多个线程同时访问这个对象的synchronized方法。如果一个对象有多个synchronized方法,只要一个线程访问了其中的一个synchronized方法,其它线程不能同时访问这个对象中任何一个synchronized方法。
这时,不同的对象实例的synchronized方法是不相干扰的。也就是说,其它线程照样可以同时访问相同类的另一个对象实例中的synchronized方法。
因为当修饰非静态方法的时候,锁定的是当前实例对象。
-
是某个类的范围,
synchronized static aStaticMethod{}
防止多个线程同时访问这个类中的synchronized static 方法。它可以对类的所有对象实例起作用。因为当修饰静态方法的时候,锁定的是当前类的 Class 对象。
-
用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。
synchronized(this){ /*区块*/ }
-
synchronized关键字是不能继承的,也就是说,基类的方法
synchronized f(){ // 具体操作 }
在继承类中并不自动是
synchronized f(){ // 具体操作 }
而是变成了
f(){ // 具体操作 }
JVM关于synchronized的两条规定:
1、线程解锁前,必须把共享变量的最新值刷新到主内存
2、线程加锁时,将清空工作内存中共享变量的值,从而是使用共享变量时,需要从主内存中重新读取最新的值(注意:加锁与解锁是同一把锁)
使用过程中,注意以下几点:
- A.无论synchronized关键字加在方法上还是对象上,它取得的锁都是对象;而不是把一段代码或函数当作锁――而且同步方法很可能还会被其他线程的对象访问。
-
B. 每个对象只有一个锁(lock)与之相关联。Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。
- C.实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。
4、生产者消费者问题
实现多线程交替执行,给资源类加锁实现同步,有两种方案:
第一种方案
传统方法使用 synchronized 结合对象监视器方法wait 与notifyall
/*
目的:有两个线程:A B ,还有一个值初始为0,
实现两个线程交替执行,对该变量 + 1,-1;交替10次
*/
public class Demo03 {
public static void main(String[] args) {
Data data = new Data();
// +1
new Thread(()->{
for (int i = 1; i <=10 ; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
// -1
new Thread(()->{
for (int i = 1; i <=10 ; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();
}
}
// 资源类
// 线程之间的通信: 判断 执行 通知
class Data{
private int number = 0;
// +1
public synchronized void increment() throws InterruptedException {
if (number!=0){ // 判断是否需要等待
this.wait(); // 当前线程等待,释放锁,直到另外一个线程调用该对象的notify()或notifyAll()方法
}
number++; // 执行
System.out.println(Thread.currentThread().getName()+"\t"+number);
// 通知
this.notifyAll(); //唤醒所有线程
}
// -1
public synchronized void decrement() throws InterruptedException {
if (number==0){ // 判断是否需要等待
this.wait(); // 等待,释放锁
}
number--; // 执行
System.out.println(Thread.currentThread().getName()+"\t"+number);
// 通知
this.notifyAll(); //唤醒所有线程
}
}
执行结果:实现了交替执行
四条线程可以实现交替吗?不能,会产生虚假唤醒问题!
public class Demo04 {
public static void main(String[] args) {
Data04 data = new Data04();
// +1
new Thread(()->{
for (int i = 1; i <=10 ; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
// +1
new Thread(()->{
for (int i = 1; i <=10 ; i++) {
try {
data.increment();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();
// -1
new Thread(()->{
for (int i = 1; i <=10 ; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"C").start();
// -1
new Thread(()->{
for (int i = 1; i <=10 ; i++) {
try {
data.decrement();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"D").start();
}
}
class Data04{
private int number = 0;
// +1
public synchronized void increment() throws InterruptedException {
if (number!=0){ // 判断是否需要等待
this.wait();
}
number++; // 执行
System.out.println(Thread.currentThread().getName()+"\t"+number);
// 通知
this.notifyAll(); //唤醒所有线程
}
// -1
public synchronized void decrement() throws InterruptedException {
if (number==0){ // 判断是否需要等待
this.wait();
}
number--; // 执行
System.out.println(Thread.currentThread().getName()+"\t"+number);
// 通知
this.notifyAll(); //唤醒所有线程
}
}
Data 类不变,执行结果:
会产生虚假唤醒问题,导致没有交替执行
查看jdk帮助文档
wait方法不能放在if判断中使用,应该放在while循环中,因为if只会判断一次,在线程被中断和虚假唤醒(就是被唤醒后线程不会去重新判断条件,而是往下走)的情况下,会导致资源数据不正确,即线程安全问题。
解决
// 修改Data,使用while代替if,解决虚假唤醒问题
class Data04{
private int number = 0;
// +1
public synchronized void increment() throws InterruptedException {
while(number!=0){ // 判断是否需要等待
this.wait();
}
number++; // 执行
System.out.println(Thread.currentThread().getName()+"\t"+number);
// 通知
this.notifyAll(); //唤醒所有线程
}
// -1
public synchronized void decrement() throws InterruptedException {
while (number==0){ // 判断是否需要等待
this.wait();
}
number--; // 执行
System.out.println(Thread.currentThread().getName()+"\t"+number);
// 通知
this.notifyAll(); //唤醒所有线程
}
}
执行结果
第二种方案
使用juc包的Lock 接口 与 Condition接口,lock替换synchronized方法和语句的使用,Condition取代了对象监视器方法的使用,可实现更精准的访问
注意,wait方法不能放在if判断中使用,应该放在while循环中,因为if只会判断一次,在线程被中断和虚假唤醒的情况下,会导致资源数据不正确,即线程安全问题。
package com.coding.demo01;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/*
实现线程交替执行!
主要的实现目标:精准的唤醒线程!
三个线程:A B C
三个方法:A p5 B p10 C p15 依次循环
*/
public class Demo05 {
public static void main(String[] args) {
Data05 data = new Data05();
new Thread(()->{
for (int i = 1; i <= 10; i++) {
try {
data.print5();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
new Thread(()->{
for (int i = 1; i <= 10; i++) {
try {
data.print10();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();
new Thread(()->{
for (int i = 1; i <= 10; i++) {
try {
data.print15();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"C").start();
}
}
// 资源类
class Data05{
private int number = 1; // 1A 2B 3C
// 一把锁
private Lock lock = new ReentrantLock();
// 实现精准访问
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
private Condition condition3 = lock.newCondition();
public void print5() throws InterruptedException {
lock.lock();
try {
// 判断
while (number!=1){
condition1.await();
}
// 执行
for (int i = 1; i <= 5; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + i);
}
// 通知第二个线程干活!
number = 2;
condition2.signal(); // 唤醒
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 一定要解锁
}
}
public void print10() throws InterruptedException {
lock.lock();
try {
// 判断
while (number!=2){
condition2.await();
}
// 执行
for (int i = 1; i <= 10; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + i);
}
// 通知3干活
number = 3;
condition3.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void print15() throws InterruptedException {
lock.lock();
try {
// 判断
while (number!=3){
condition3.await();
}
// 执行
for (int i = 1; i <= 15; i++) {
System.out.println(Thread.currentThread().getName() + "\t" + i);
}
// 通知 1 干活
number = 1;
condition1.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
执行结果,达到目的线程A、B、C交替执行
总结
新的技术出来,一定是可以替换一些旧技术的!
5、八锁现象线程彻底理解锁
要区分synchronized 锁的是对象还是类模版,是两个不同的锁。下面是例子代码
synchronized 锁对象1
/**
1、标准的访问情况下,先执行 sendEmail 还是 sendSMS
答案:sendEmail
被 synchronized 修饰的方式,锁的对象是方法的调用者,所以说这里两个方法调用的对象是同一个
先调用的先执行!
*/
public class LockDemo1 {
public static void main(String[] args) throws InterruptedException {
Phone phone = new Phone();
new Thread(() -> {
phone.sendEmail();
},"A").start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {
phone.sendSMS();
},"B").start();
}
}
class Phone {
public synchronized void sendEmail() {
System.out.println("sendEmail");
}
public synchronized void sendSMS() {
System.out.println("sendSms");
}
}
synchronized 锁对象2
/**
* 2、sendEmail休眠3秒后 ,先执行 sendEmail 还是 sendSMS
* 答案:sendEmail
* 被 synchronized 修饰的方式,锁的对象是方法的调用者,所以说这里两个方法调用的对象是同一个
* 先调用的先执行!
*/
public class LockDemo2 {
public static void main(String[] args) throws InterruptedException {
Phone2 phone = new Phone2();
new Thread(() -> {
try {
phone.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A").start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {
phone.sendSMS();
},"B").start();
}
}
class Phone2 {
public synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public synchronized void sendSMS() {
System.out.println("sendSms");
}
}
synchronized 锁对象3
/**
* 3、一个普通方法,一个synchronized 修饰的方法,先执行sendEmail还是sendSMS
*
* 答案:sendSMS
* sendSMS方法没有synchronized 修饰,不是同步方法,不受锁影响
*/
public class LockDemo3 {
public static void main(String[] args) throws InterruptedException {
Phone3 phone = new Phone3();
new Thread(() -> {
try {
phone.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A").start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {
phone.sendSMS();
},"B").start();
}
}
class Phone3 {
public synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public void sendSMS() {
System.out.println("sendSms");
}
}
synchronized 锁不同对象4
/**
* 4、两个手机,请问先执行sendEmail 还是 sendSMS
* 答案:sendSMS
* 被 synchronized 修饰的方法,锁的对象是调用者;我们这里有两个调用者,两个方法在这里是两个锁
*/
public class LockDemo4 {
public static void main(String[] args) throws InterruptedException {
Phone4 phone1 = new Phone4();
Phone4 phone2 = new Phone4();
new Thread(() -> {
try {
phone1.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
phone2.sendSMS();
},"B").start();
}
}
class Phone4 {
public synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public synchronized void sendSMS() {
System.out.println("sendSms");
}
}
synchronized 锁Class类 5
/**
* 5、两个静态同步方法,同一个手机访问,先执行sendEmail还是sendSMS
*
* 答案:sendEmail
* 方法被static 修饰,synchronized 锁的对象的Class 类模版,这个全局唯一,所以说这里说同一个锁
*/
public class LockDemo5 {
public static void main(String[] args) throws InterruptedException {
Phone5 phone = new Phone5();
new Thread(() -> {
try {
phone.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
},"A").start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
phone.sendSMS();
},"B").start();
}
}
class Phone5 {
public static synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public static synchronized void sendSMS() {
System.out.println("sendSMS");
}
}
synchronized 锁Class类模版 6
/**
* 6、两个静态同步方法,两个手机访问,先执行sendEmail,还是sendSMS
*
* 答案:sendEmail
* 只要方法被static修饰,synchronized锁的对象锁Class类模版,全局唯一,所以说这里是同一个锁,谁先获取谁先执行,执行完毕才释放锁
*
*/
public class LockDemo6 {
public static void main(String[] args) throws InterruptedException {
Phone6 phone1 = new Phone6();
Phone6 phone2 = new Phone6();
new Thread(() -> {
try {
phone1.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
phone2.sendSMS();
}).start();
}
}
class Phone6 {
public static synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public static synchronized void sendSMS() {
System.out.println("sendSMS");
}
}
synchronized 锁Class类模版和调用的对象,两个不同的锁 7
/**
* 7、一个静态同步方法,一个普通同步方法,只有一个手机,先执行sendEmail还是sendSMS
* 答案:sendSMS
* 静态同步方法,锁的是Class类模版
* 普通同步方法,锁的是方法调用的对象
* 这里是两个不同的锁
*/
public class LockDemo7 {
public static void main(String[] args) throws InterruptedException {
Phone7 phone = new Phone7();
new Thread(() -> {
try {
phone.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
phone.sendSMS();
}).start();
}
}
class Phone7 {
public static synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public synchronized void sendSMS() {
System.out.println("sendSMS");
}
}
synchronized 锁Class类模版和调用的对象,两个不同的锁 8
/**
* 8、一个静态同步方法,一个普通同步方法,两个手机,先输出sendEmail还是sendSMS
* 答案:sendSMS
* 静态同步方法,锁的是Class类模版
* 普通同步方法,锁的是方法调用的对象
* 这里是两个不同的锁
*/
public class LockDemo8 {
public static void main(String[] args) throws InterruptedException {
Phone8 phone1 = new Phone8();
Phone8 phone2 = new Phone8();
new Thread(() -> {
try {
phone1.sendEmail();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
TimeUnit.SECONDS.sleep(1);
new Thread(() -> {
phone2.sendSMS();
}).start();
}
}
class Phone8 {
public static synchronized void sendEmail() throws InterruptedException {
TimeUnit.SECONDS.sleep(3);
System.out.println("sendEmail");
}
public synchronized void sendSMS() {
System.out.println("sendSMS");
}
}
小结
new this 一个具体的对象
Static class 类,唯一的模版
我们编写多线程时,只要搞明白锁的是什么就不出错了。
6、不安全的集合类
只要是并发环境,你的集合类都是不安全的(List、Map、Set)
List不安全
public class UnsafeList {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
运行
再运行
按正常逻辑ArrayList应该有3个随机字符串,但多次运行结果都是不对的甚至还报异常,说明并发线程下ArrayList不安全。
ConcurrentModificationException 并发修改异常
导致原因:add方法没有加锁,我们点进ArrayList的add方法看看源码,确实没有加锁
解决方案1:使用Vector
public class UnsafeList {
public static void main(String[] args) {
List<String> list = new Vector<>();
for (int i = 0; i < 30; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
运行结果
再没有报ConcurrentModificationException了,我们看看Vector的add方法源码
add方法加了 同步锁,所以并发线程下它是安全的。Vector 是从JDK1.0就有的啦
而ArrayList是JDK1.2才有的,它为什么没有加锁尼?
因为使用Vector 加上synchronized 同步锁 ,效率低,
解决方案2:使用Collections.synchronizedList
public class UnsafeList {
public static void main(String[] args) {
/// List<String> list = new Vector<>(); Jdk1.0就存在,效率低
List<String> list = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 30; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
运行结果
结果正常,没有报ConcurrentModificationException。
解决方案3:使用java.util.concurrent.CopyOnWriteArrayList
CopyOnWriteArrayList 是一种拷贝思想
public class UnsafeList {
public static void main(String[] args) {
/// List<String> list = new Vector<>(); Jdk1.0就存在,效率低
//List<String> list = Collections.synchronizedList(new ArrayList<>())
List<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 30; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
运行结果
结果正常,没有报ConcurrentModificationException。
什么是CopyOnWrite: 写入是复制(COW)
多个调用者使用相同资源时,有一个指针的概念,如下图:
List写完后,指针会移动到最新资源,读写分离的思想。
我们点进CopyOnWriteArrayList,看看add的源码
逻辑是不是上面的指针移动一样。
Set 不安全
public class UnsafeSet {
public static void main(String[] args) {
// HashSet 底层是什么,点击源码它是一个new HashMap()
Set<String> set = new HashSet<>();
for (int i = 0; i <30 ; i++) {
new Thread(() -> {
set.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(set);
}).start();
}
}
}
运行:
不出意外,报ConcurrentModificationException 并发修改异常,说明Set在并发环境下是不安全的。
解决方案1:使用Collections.synchronizedSet
public class UnsafeSet {
public static void main(String[] args) {
// HashSet 底层是什么,点击源码它是一个new HashMap()
//Set<String> set = new HashSet<>();
Set<String> set = Collections.synchronizedSet(new HashSet<>());
for (int i = 0; i <30 ; i++) {
new Thread(() -> {
set.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(set);
}).start();
}
}
}
运行结果:
结果正常,没有报ConcurrentModificationException。
解决方案2:使用java.util.concurrent.CopyOnWriteArraySet
public class UnsafeSet {
public static void main(String[] args) {
// HashSet 底层是什么,点击源码它是一个new HashMap()
//Set<String> set = new HashSet<>();
//Set<String> set = Collections.synchronizedSet(new HashSet<>());
Set<String> set = new CopyOnWriteArraySet<>();
for (int i = 0; i <30 ; i++) {
new Thread(() -> {
set.add(UUID.randomUUID().toString().substring(0,3));
System.out.println(set);
}).start();
}
}
}
运行结果
结果正常,没有报ConcurrentModificationException。
聊聊HashSet
本质是一个HashMap,点进源码看它的add方法,本质是put一个常量。
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
/**
* Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
* default initial capacity (16) and load factor (0.75).
*/
public HashSet() {
map = new HashMap<>();
}
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
Map不安全
public static void main(String[] args) {
// new HashMap(),工作中不能这样用
// 因为默认容量是16,扩展容量是损耗性能的,所以应该确认初始化的容量
// HashMap的底层数据结构 链表+红黑树
Map<String,String> map = new HashMap<>(30);
for (int i = 0; i <30 ; i++) {
new Thread(() -> {
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,3));
System.out.println(map);
}).start();
}
}
}
运行
解决方案:使用java.util.concurrent.ConcurrentHashMap
public static void main(String[] args) {
Map<String,String> map =new ConcurrentHashMap <>(30);
for (int i = 0; i <30 ; i++) {
new Thread(() -> {
map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,3));
System.out.println(map);
}).start();
}
}
运行结果
结果正常,没有报异常
Map的值覆盖问题
多线程环境下,使用HashMap除了容易发生上面的并发异常外,可能会发生值覆盖问题。
JDK1.7 扩容时
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e; // 线程 A 运行到这里时被挂起
e = next;
}
}
}
在扩容时,发生数据覆盖问题主要核心就是上面的代码,我们假设一下,刚开始时,结构是这样的:
现在有两个线程 A 和 B ,它们都要进行插入操作,首先 A 进行插入操作,经过 Hash 之后得到了要落到的桶的索引坐标,运行到 newTable[i] = e;
这行代码时, CPU 时间片用完了,此时线程 A 就停止运行被挂起,这个时候是这个样子的:
线程 A 被挂起之后,线程 B 被调度得以运行,巧的是,线程 B 经过 Hash 之后得到的要落到的桶索引坐标和线程 A 一样,此时线程 B 也进行插入操作,线程 B 因为时间片足够用,所以就成功的将记录插入到了桶里面:
线程 B 插入成功之后,根据 Java 内存模型,此时主内存中存放的值就是线程 B 运行之后的结果
接下来线程 A 被唤醒,继续执行插入操作。对于 A 来说,前面的步骤都已经执行过了,所以就不需要再次运行,直接从 newTable[i] = e;
这行代码开始往下继续运行即可,线程 A 保存的环境是 e = 12
next = 6
e.next = newTable[i]; 即 newTable[3] = null;
,那么接下来执行 newTable[i] = e;
& e = next
也就是 newTable[3] = 12
e = next = 6
执行完毕之后,大概就是这样:
元素 15 就这么被覆盖掉了
JDK1.8 put
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有 hash 碰撞,则直接插入
tab[i] = newNode(hash, key, value, null);
数据覆盖主要发生在 put 操作中,在上面的代码中,我们能够看到,源码只是判断了 hash 是否有碰撞,如果没有就不再做别的检查进行插入操作。
在多线程环境下,如果线程 1 检查完了 hash 没有碰撞,要进行插入时, CPU 时间片使用完毕,此时它被挂起,线程 2 开始跑,无巧不成书嘛,此时线程 2 经过 hash 之后得到的值和线程 1 的 hash 值一样,线程 2 将值插入进去,线程 1 恢复运行,因为前面检查了 hash 碰撞,此时插入时不再做任何检查,直接将值插入。那么线程 2 插入的值就被覆盖掉了
HashMap 之所以发生数据覆盖的问题,最主要的原因在于它没有加锁,所以在多线程环境下会发生数据覆盖问题
小结
以后并发环境中,使用juc包中的CopyOnWriteArrayList、CopyOnWriteArraySet、ConcurrentHashMap替代ArrayList、ArraySet、HashMap。
7、HashMap
put操作逻辑
Hashmap具有如下特性:
- 多线程情况下该类不安全,可以考虑用 HashTable。
- HashMap 的存取是没有顺序的,KV 均允许为 NULL
- JDk8底层是数组 + 链表 + 红黑树,JDK7底层是数组 + 链表。
- 初始容量和装载因子是决定整个类性能的关键点,轻易不要动。
- HashMap是懒汉式创建的,只有在你put数据时候才会 build。
- 单向链表转换为红黑树的时候会先变化为双向链表最终转换为红黑树,切记双向链表跟红黑树是
共存
的。 - 对于传入的两个
key
,会强制性的判别出个高低,目的是为了决定向左还是向右放置数据。 - 链表转红黑树后会努力将红黑树的
root
节点和链表的头节点 跟table[i]
节点融合成一个。 - 在删除的时候是先判断删除节点红黑树个数是否需要转链表,不转链表就跟
RBT
类似,找个合适的节点来填充已删除的节点。 - 红黑树的
root
节点不一定
跟table[i]
也就是链表的头节点是同一个,三者同步是靠MoveRootToFront
实现的。而HashIterator.remove()
会在调用removeNode
的时候movable=false
。
常见HashMap考点:
- HashMap原理,内部数据结构。
- HashMap中的put、get、remove大致过程。
- HashMap中 hash函数实现。
- HashMap如何扩容。
- HashMap几个重要参数为什么这样设定。
- HashMap为什么线程不安全,如何替换。
- HashMap在JDK7跟JDK8中的区别。
- HashMap中链表跟红黑树切换思路。
底层数据结构
在jdk1.7和1.8版本的实现方式不一样
-
JDK 1.7 ,数组+链表
-
JDK 1.8 ,数组+链表+红黑树
很明显就能看出来, 1.8 版本怎么多了一个树?还是红黑的?
这就要来分析 1.7 版本 HashMap 的实现有什么不足了
jdk1.7 采用数组 + 链表的方式实现,如果有一个 hash 值总是会发生碰撞,那么由此对应的链表结构也会越来越长,这个时候如果再想要进行查询操作,就会非常耗时,所以该如何优化这一点就是jdk1.8要实现的,引入红黑树的方式
jdk1.8采用了数组 + 链表 + 红黑树的方式去实现,
-
当链表的长度大于 8 时,就会将链表转为红黑树。
为什么会将链表转红黑树的值设定为 8 ?
因为链表的时间复杂度是
n/2
,红黑树时间复杂度是logn
,当 n 等于 8 的时候, log8 要比 8/2 小,这个时候红黑树的查找速度会更快一些 -
当数组长度小于 6 的时候转为链表,而不是 7 的时候就转为链表呢?频繁的从链表转到红黑树,再从红黑树转到链表,开销会很大,特别是频繁的从链表转到红黑树时,需要旋转
面试问答
1、为什么将链表转为红黑树,而不是平衡二叉树( AVL 树)呢?
a) 二叉树比红黑树保持着更加严格的平衡, 二叉树中从根到最深叶的路径最多为 1.44lg(n + 2)
,红黑树中则最多为 2lg( n + 1)
,所以二叉树查找效果会比较快,如果是查找密集型任务使用二叉树比较好。相反插入密集型任务,使用红黑树效果就比较快
b) 二叉树在每个节点上都会存储平衡因子
c) 二叉树的旋转比红黑树的旋转更加难以平衡和调试,如果两个都给 O(lgn) 查找, 二叉树可能需要 O(log n) 旋转,而红黑树最多需要两次旋转使其达到平衡。
2、HashMap
内部的bucket
数组长度为什么一直都是2的整数次幂
答:这样做有两个好处,第一,可以通过(table.length - 1) & key.hash()
这样的位运算快速寻址,第二,在HashMap
扩容的时候可以保证同一个桶中的元素均匀的散列到新的桶中,具体一点就是同一个桶中的元素在扩容后一般留在原先的桶中,一般放到了新的桶中。
3、HashMap
默认的bucket
数组是多大
答:默认是16,如果指定的大小不是2的整数次幂,HashMap
也会找到一个最接近的2的整数次幂来初始化桶数组。
4、HashMap
何时扩容
答:当HashMap
中的元素数量超过阈值时,阈值计算方式是capacity * loadFactor
,在HashMap
中loadFactor
是0.75
5、桶中的元素链表何时转换为红黑树,什么时候转回链表,为什么要这么设计?
答:当同一个桶中的元素数量大于等于8的时候元素中的链表转换为红黑树,反之,当桶中的元素数量小于等于6的时候又会转为链表,这样做的原因是避免红黑树和链表之间频繁转换,引起性能损耗
6、Java 8
中为什么要引进红黑树,是为了解决什么场景的问题?
答:引入红黑树是为了避免hash
性能急剧下降,如同一hash对应的链表过长引起查询性能下降的问题,正常情况下,一般是不会用到红黑树的,在一些极端场景下,假如客户端实现了一个性能拙劣的hashCode
方法,可以保证HashMap
的读写复杂度不会低于O(lgN)
public int hashCode() {
return 1;
}
7、HashMap
如何处理key
为null
的键值对?
答:放置在桶数组中下标为0的桶中
8、ConcurrentHashMap
ConcurrentHashMap是多线程模式下常用的并发容器,它的实现在JDK7跟JDK8区别挺大的。
底层数据结构
JDK7中ConcurrentHashMap
采用分段锁( ReentrantLock + Segment + HashEntry )实现,也就是将一个 HashMap 分成多个段,然后每一段都分配一把锁,这样去支持多线程环境下的访问。但是这样锁的粒度太大了,因为你锁的直接就是一段嘛,并发程度是由Segment
数组个数来决定的,并发度一旦初始化无法扩容,扩容的话只是HashEntry
的扩容。
Segment
继承自 ReentrantLock
,在此扮演锁的角色。可以理解为我们的每个Segment
都是实现了Lock
功能的HashMap
,看上图Segment
数组的长度是15,所以最高并发是15个线程。
put流程如下:
面试问答:
1、ConcurrentHashMap
底层大致实现?
答:ConcurrentHashMap允许多个修改操作并发进行
,其关键在于使用了锁分离技术
。它使用了多个锁来控制对hash表的不同部分进行的修改。内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的HashTable,只要多个修改操作发生在不同的段上就可以并发进行
2、ConcurrentHashMap
在并发下的情况下如何保证取得的元素是最新的?
用于存储键值对数据的HashEntry
,在设计上它的成员变量value跟next都是volatile
类型的,这样就保证别的线程对value值的修改,get方法可以马上看到,并且get的时候是不用加锁的。
3、ConcurrentHashMap的弱一致性体现在clear和get方法,原因在于没有加锁。
比如迭代器在遍历数据的时候是一个Segment一个Segment去遍历的,如果在遍历完一个Segment时正好有一个线程在刚遍历完的Segment上插入数据,就会体现出不一致性。clear也是一样。get方法和containsKey方法都是遍历对应索引位上所有节点,都是不加锁来判断的,如果是修改性质的因为可见性的存在可以直接获得最新值,不过如果是新添加值则无法保持一致性。
4、size 统计个数不准确
size方法比较有趣,先无锁的统计所有的数据量看下前后两次是否数据一样,如果一样则返回数据,如果不一样则要把全部的segment进行加锁,统计,解锁。并且size方法只是返回一个统计性的数字。
JDK8中ConcurrentHashMap
JDK8做了优化,使用 CAS + synchronized + Node + 红黑树 来实现,这样就将锁的粒度降低了,同时使用 synchronized 来加锁,相比于 ReentrantLock 来说,会节省比较多的内存空间
put流程如下:
ConcurrentHashMap 是如何做到高效并发安全
?
-
读操作
get方法中根本没有使用同步机制,也没有使用unsafe方法,所以读操作是支持并发操作的。
-
写操作
基本思路跟HashMap的写操作类似,只不过用到了CAS + syn 实现加锁,同时还涉及到扩容的操作。JDK8中锁已经细化到 table[i] 了,数组位置不同可并发,位置相同则去帮忙扩容。
-
同步处理主要是通过syn和unsafe的硬件级别原子性这两种方式完成
当我们对某个table[i]操作时候是用syn加锁的。
取数据的时候用的是unsafe硬件级别指令,直接获取指定内存的最新数据。
HashTable的扩容流程如下:
- 首先new一个新的hash表(nextTable)出来,大小是原来的2倍。后面的rehash都是针对这个新的hash表操作,不涉及原hash表(table)。
- 然后会对原hash表(table)中的每个链表进行rehash,此时会尝试获取头节点的锁。这一步就保证了在rehash的过程中不能对这个链表执行put操作。
- 通过sizeCtl控制,使扩容过程中不会new出多个新hash表来。
- 最后,将所有键值对重新rehash到新表(nextTable)中后,用nextTable将table替换。这就避免了HashMap中get和扩容并发时,可能get到null的问题。
- 在整个过程中,共享变量的存储和读取全部通过volatile或CAS的方式,保证了线程安全。
JDK8做了优化,优点在哪里
-
减少内存开销
假设使用可重入锁ReentrantLock ,那么每个节点都需要继承AQS,但并不是每个节点都需要同步支持,只有链表的头节点(红黑树的根节点)需要同步,这无疑消耗巨大内存。
-
获得JVM的支持
可重入锁ReentrantLock 毕竟是API级别的,后续的性能优化空间很小。synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。