JUC-1
JUC复习
1、进程、线程概念,两者之间的区别
进程:是程序的一次执行,是系统进行资源分配和调度的独立单位,每一个进程都有它自己的内存空间和系统资源。
线程:同一个进程之间又可以执行多个任务,每个任务可以看成是一个线程。一个进程有1到多个线程。
区别 :
根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
资源开销:每个进程都有独立的代码和数据空间,程序之间切换会有较大的开销;线程可以看作轻量级进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(program counter),线程之间切换的开销相对于进程小。
包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线共同完成,线程是进程的一部分,又称轻权进程或轻量级进程。
内存分配:同一进程内的线程共享本进程中的地址空间和资源,而进程之间的资源不共享各自独立。
影响关系:一个进程崩溃在系统的保护模式下不会影响其它进程,但如果一个线程崩溃很大概率造成整个程序崩溃。
执行过程:每个独立的进程都有程序运行的入口、顺序执行的序列和程序的出口。线程的执行不能独立于应用程序之外,需要依存于应用程序,由应用程序提供多个线程的执行。
2、管程(monitor监视器)
管程就是monitor,也就是常说的锁,每一个对象都自带monitor对象(由JVM底层c++代码实现),也就是每个对象都可以作为锁。JVM中同步是基于进入(monitorenter)和退出(monitorexit)监视器对象来实现的。
3、用户线程和守护线程
普通用户线程调用setDaemon(true)方法即可将线程设置为守护线程,守护线程是一种特殊的线程,在后台静默运行,通常用来完成一些系统性的任务,比如JVM的垃圾回收线程。
用户线程是系统的工作线程,需要它来完成程序需要完成的操作。
守护线程会在所有用户线程退出后也会运行结束。
4、CompletableFuture
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
在JDK8中,CompletableFuture提供了强大的Futrue扩展功能,简化了异步编程的复杂性。提供了函数式编程的能力,可以通过回调的方式处理结果,也提供了组合(Combine)和转换CompletableFuture的方法。
4.1 CompletionStage
代表异步运行的一个阶段,一个阶段完成后可能会触发另一个阶段,类似Linux中的管道(|)。
4.2 核心方法
-
无返回值runAsync
public static CompletableFuture<Void> runAsync(Runnable runnable) // ⚠️可以传入自定义的线程池,不穿入则使用默认线程池ForkJoinPool.commonPool(),只有一个线程 public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
-
有返回值supplyAsync
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
简单演示使用:
public class CompletableFutureTest {
public static void main(String[] args) {
CompletableFuture.supplyAsync(() -> {
System.out.println(Thread.currentThread().getName() + " is come in");
int r = ThreadLocalRandom.current().nextInt();
try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); }
if (r > 8) {
int i = 1 / 0;
}
return r;
}).whenComplete((u, e) -> { // 异步回调。上方异步任务执行完成后会回调该方法
if (e == null) {
System.out.println("current u is " + u);
}
}).exceptionally(e -> { // 异步任务中出错时回调
System.out.println(e.getMessage());
return -1;
});
try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); }
}
}
/**由上面的例子可以看出CompletableFuture可以看出无论是异步线程处理结束或者出现异常都可以通过调用相应的回调
* 方法来进行下一步处理,而不像Future那般需要阻塞主线程。
*/
4.3 CompletableFuture的常用方法
5、各种锁
5.1 悲观锁,乐观锁
悲观锁:
在使用共享数据的时候始终认为有其它线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被他人修改。synchronized和Lock的实现类都是悲观锁。
适合写操作多的场景,先加锁可以保证写操作时的数据正确。显式锁定以保证同步
乐观锁:
认为在使用共享数据时不会有其它线程来操作修改数据,因此没有加锁,只有去更新数据时才会判断此数据是否有被修改,如果没有被修改那么将本线程修改后的数据保存,如果修改失败那么根据不同的实现方式来做不同的操作。乐观锁就是通过无锁编程来实现,常见的有CAS算法,Java原子包下的递增方法就是通过CAS来实现的。
适合读多的场景,不加锁使读的性能大幅提升;
实现乐观锁的办法一般有2种:1是对数据添加版本号version,2是使用CAS(compare and swap)算法实现。
5.2 简单分析一下synchronized(后续详述)
锁在哪里?
JVM中对应的锁在 class文件的文件开头有特定的标识!!
synchronized三种应用方式:
1、加在代码块上,需要获取synchronized后配置的对象的锁。
2、如果加在普通实例方法上那么进入改实例方法前需要获得该实例的锁。
3、加在静态方法上,需要获取该类的锁,锁的字节码对象。
从字节码的角度来分析锁:
反编译字节码文件:javap -c XXX.class 或者使用 -v获取更详细的附加信息【输出附加信息(包括行号、本地变量表,反汇编等详细信息)】
- 代码块上加锁
通过字节码反编译后可以看出,JVM是通过monitorenter和monitorexit指令来实现同步。
问题:
1、一个monitorenter为什么对应2个monitorexit,为什么不是一一匹配?
因为多出来的那个是当程序发生了exception或者error时来保障释放锁的
2、一定是一个monitorenter对应2个monitorexit吗?
不一定,如果抛出异常那么monitorenter和monitorexit只有一对。见下面字节码截图:
-
普通实例方法加锁
调用指令或检查方法的ACC_SYNCHRONIZED是否被设置,执行线程会先去持有对象里的monitor(JVM源码层面)然后执行方法,方法完成后(无论正常完成还是非正常完成-出现异常等)释放monitor。
-
静态方法加锁:
5.3 synchronized到底锁的什么
再说管程monitor:
管程 (英语:Monitors,也称为监视器) 是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。
这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。
在HotSpot中monitor采用ObjectMonitor实现,从Java对应到底层C++的关系为ObjectMonitor.java→ObjectMonitor.cpp→objectMonitor.hpp
monitor 的机制中,monitor object 充当着维护 mutex以及定义 wait/signal API 来管理线程的阻塞和唤醒的角色。 Java 语言中的 java.lang.Object 类,便是满足这个要求的对象,故任何一个 Java 对象都可以作为 monitor 机制的 monitor object。
5.4 公平锁和非公平锁
公平锁见名知意,就是要满足先来后到的原则,也就是FIFO(先进先出,先来先办事后来者等着);非公平锁则恰恰相反充满随机性。
synchronized和ReentrantLock(默认状态)下都为非公平锁,而ReentrantLock可以调用其有参构造设置fair为true从而可以使其走公平锁的底层实现,其核心逻辑就是底层添加了一个队列来保证公平。
Q:为什么会有公平锁和非公平锁的设计?
恢复被暂挂的线程到获取到锁还是有时间差的,对于人来说基本感受不到但是从CPU角度来看这个时间差还是很明显的,所以非公平能够充分利用CPU的时间片减少空闲时间开销。使用多线程很重要的一点就是要减少线程上下文切换的开销,而如果使用非公平锁,那么当一个线程从获取到同步状态到释放同步状态后无需像公平锁那样考虑前前置对象,从而当前线程在释放同步状态后能够大概率的再次获取到同步状态,减少了线程切换的开销。
但非公平锁可能会造成锁饥饿
问题,也就是一个线程吃到饱,其它线程等到死。
Q:何时使用公平锁,何时使用非公平锁?
公平锁的优点是按序平均分配锁资源,不会出现线程饿死的情况,它的缺点是按序唤醒线程的开销大,执行性能不高。 非公平锁的优点是执行效率高,谁先获取到锁,锁就属于谁,但缺点是资源分配随机性强,可能会出现线程饿死的情况。
所以如果为了吞吐量那么选择非公平锁,如果为了保证某些线程不被饿死
那么选用公平锁。
5.5 可重入锁(递归锁)
可重入锁又称递归锁,指同一个线程在方法外层获取到锁之后,进入方法内层如果遇到需要再次获取同一把锁的情况那么将会自动获取该锁并进入方法内部继续执行,并不会说外层锁没有释放锁而导致内层等锁然后造成阻塞。
可重入锁种类
-
隐式锁:synchronized默认就是可重入锁 (指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。
简单的来说就是:在一个synchronized修饰的方法或代码块的内部调用本类的其他synchronized修饰的方法或代码块时,是永远可以得到锁的)
同步代码块
同步方法
-
显式锁:Lock就是一类显式锁,而ReentrantLock又是可重入锁。
import java.util.concurrent.locks.ReentrantLock; // 此例子中加锁解锁一定要配对,否则会出现锁没释放导致死锁 public class ReentrantLockTest { public static void main(String[] args) { ReentrantLock reentrantLock = new ReentrantLock(); new Thread(() -> { reentrantLock.lock(); try { System.out.println("哈哈哈,我在外面"); reentrantLock.lock(); try { System.out.println("哈哈哈,我在里面"); } finally { reentrantLock.unlock(); } } finally { reentrantLock.unlock(); } }).start(); new Thread(() -> { reentrantLock.lock(); try { System.out.println("b thread----外层调用lock"); } finally { reentrantLock.unlock(); } }, "b").start(); } }
5.6 死锁及排查方法
死锁通常是由于两个及两个以上的线程在运行的过程中争抢资源导致互相等待的现象。如果没有外界干涉它们的任务都将无法进行下去,如果系统资源充足,进程的资源请求都能得到满足,死锁出现的几率就会大大降低,否则就会因争夺有限的资源陷入死锁。
产生死锁的四要素
- 互斥条件:至少有一个资源是独占的,即一次只能由一个进程使用。
- 请求与保持条件:一个进程在持有一个资源的同时继续请求另一个资源。
- 不剥夺条件:已经分配给一个进程的资源不能被强制性地剥夺,只能由持有它的进程自行释放。
- 环路等待条件:存在一个进程资源的环形链,每个进程都在等待下一个进程所持有的资源。
产生死锁的主要原因:
- 系统资源不足
- 进程运行推进顺序不恰当
- 资源分配不当
死锁案例:
public class DeadLockTest {
final static Object l1 = new Object();
final static Object l2 = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (l1) {
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println("1111");
synchronized (l2) {
System.out.println("2222");
}
}
}, "t1").start();
new Thread(() -> {
synchronized (l2) {
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println("haha");
synchronized (l1) {
System.out.println("hehe");
}
}
}, "t2").start();
}
}
通过工具研究排查上面的死锁
5.6.1、jps + jstack
**JPS 名称:** jps - Java Virtual Machine Process Status Tool
**命令用法:** jps [options] [hostid]
options:命令选项,用来对输出格式进行控制
hostid:指定特定主机,可以是ip地址和域名, 也可以指定具体协议,端口。
**[protocol:][[//]hostname][:port][/servername]**
**功能描述: **jps是用于查看有权访问的hotspot虚拟机的进程. 当未指定hostid时,默认查看本机jvm进程,否则查看指定的hostid机器上的jvm进程,此时hostid所指机器必须开启jstatd服务。 jps可以列出jvm进程lvmid,主类类名,main函数参数, jvm参数,jar名称等信息。
➜ ~ jstack --help
Usage:
jstack [-l] <pid>
(to connect to running process)
jstack -F [-m] [-l] <pid>
(to connect to a hung process)
jstack [-m] [-l] <executable> <core>
(to connect to a core file)
jstack [-m] [-l] [server_id@]<remote server IP or hostname>
(to connect to a remote debug server)
Options:
-F to force a thread dump. Use when jstack <pid> does not respond (process is hung)
-m to print both java and native frames (mixed mode)
-l long listing. Prints additional information about locks
-h or -help to print this help message
通过jps -l可以找到当前死锁测试类的jvm进程lvmid,主类类名(入口)
再使用jstack lvmid来获取栈信息,可以清晰得看出有一个死锁,分别位于15行和26行处互相等待造成死锁。
5.6.2、使用JDK自带的jconsole图形化工具也可进行死锁检测
命令行输入jconsole即可打开图形化工具,前提有配置JDK的环境变量。
5.7 读(共享锁)写(排他锁)锁
为啥子要有读写锁呢,当然还是为了提升效率,同一时间多个读操作可以同时进行,而写操作进行时其它操作都要等到,避免脏读。如果使用独占锁那么无论是读还是写在进行时其它的操作都要等到起,执行效率明显不如读写锁;但是读写锁也有弊端那就是读线程过多,导致写线程迟迟得不到时间片无法执行!
『读写锁ReentrantReadWriteLock』并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的,
大多实际场景是“读/读”线程间并不存在互斥关系,只有"读/写"线程或"写/写"线程间的操作需要互斥的。因此引入ReentrantReadWriteLock。
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁。
也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。只有在读多写少情境之下,读写锁才具有较高的性能体现。
读写锁例子:
package cc.xuhao.concurrencylearn;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockTest {
public static void main(String[] args) {
MyResource myResource = new MyResource();
for (int i = 0; i < 10; i++) {
final int ii = i;
new Thread(() -> {
myResource.write(Objects.toString(ii), "ttt" + ii);
}, "t" + i).start();
}
for (int i = 0; i < 10; i++) {
final int ii = i;
new Thread(() -> {
myResource.read(Objects.toString(ii));
}, "t" + i).start();
}
try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); }
for (int i = 0; i < 3; i++) {
final int ii = i;
new Thread(() -> {
myResource.write(Objects.toString(ii), "ttt" + ii);
}, "t" + i).start();
}
}
}
class MyResource {
private Map<String, Object> map = new HashMap<>();
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void read(String k) {
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"\t"+"---正在读取---" + System.currentTimeMillis());
Object r = map.get(k);
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t"+"---完成读取result:"+r);
} finally {
lock.readLock().unlock();
}
}
public void write(String k, Object v) {
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName()+"\t"+"---正在写入");
map.put(k, v);
try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); }
System.out.println(Thread.currentThread().getName()+"\t"+"---完成写入");
} finally {
lock.writeLock().unlock();
}
}
}
运行结果:
t1 ---正在写入
t1 ---完成写入
t2 ---正在写入
t2 ---完成写入
t4 ---正在写入
t4 ---完成写入
t5 ---正在写入
t5 ---完成写入
t3 ---正在写入
t3 ---完成写入
t6 ---正在写入
t6 ---完成写入
t0 ---正在写入
t0 ---完成写入
t7 ---正在写入
t7 ---完成写入
t8 ---正在写入
t8 ---完成写入
t9 ---正在写入
t9 ---完成写入
t4 ---正在读取---1677815078469 # 可以看出读取几乎都在一瞬间同时开始的,并没有发生阻塞
t0 ---正在读取---1677815078470
t5 ---正在读取---1677815078470
t6 ---正在读取---1677815078470
t8 ---正在读取---1677815078470
t1 ---正在读取---1677815078470
t7 ---正在读取---1677815078470
t3 ---正在读取---1677815078470
t2 ---正在读取---1677815078470
t9 ---正在读取---1677815078470
t5 ---完成读取result:ttt5
t4 ---完成读取result:ttt4
t1 ---完成读取result:ttt1
t0 ---完成读取result:ttt0
t7 ---完成读取result:ttt7
t3 ---完成读取result:ttt3
t2 ---完成读取result:ttt2
t9 ---完成读取result:ttt9
t8 ---完成读取result:ttt8
t6 ---完成读取result:ttt6
t0 ---正在写入
t0 ---完成写入
t1 ---正在写入
t1 ---完成写入
t2 ---正在写入
t2 ---完成写入
Process finished with exit code 0
锁降级
遵循获取写锁,然后再获取读锁再释放写锁的次序,写锁能够降级为读锁。
根据JAVASE 8中文档关于锁降级的说明:
- 写锁可以降级为读锁,即先获取写锁,然后获取读锁,最后释放写锁,这样可以实现锁的降级;
- 读锁不能升级为写锁
例子:
public class ReentrantReadWriteLockDowngradeTest {
// 如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。
public static void main(String[] args) {
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 获取写锁
lock.writeLock().lock();
// 写操作
System.out.println("写点东西");
// 获取读锁,这时候锁已经降级为读锁
lock.readLock().lock();
// 释放写锁
lock.writeLock().unlock();
// 读操作
System.out.println("读点东西");
// 释放读锁
lock.readLock().unlock();
}
}
上面的例子演示了从写锁可以降级为读锁的情况,但当读锁被使用时,如果有线程尝试获取写锁,该写线程会被阻塞。所以,需要释放所有读锁,才可获取写锁。
写锁和读锁是互斥的
写锁和读锁是互斥的,互斥是指线程之间的互斥,当前线程可以既获取到写锁也可以同时获取到读锁,但是不能同时获取到读锁又再获取写锁,因为读写锁要保证写线程的可见性。
如果允许读锁在被获取的情况下对写锁的获取,那么正在运行的其他读线程无法感知到当前写线程的操作。
ReentrantReadWriteLock的暴露出来的问题
因为读写锁互斥,所以只有等读锁完成释放后才能获取到写锁,而当写锁定时,所有的读操作又要全部等待。这是一种悲观读,肯定没有读的过程中也可以写入性能高。后面再出的邮戳锁(StampedLock)就可以解决此问题。
JavaSE8 文档上读写锁的例子:
class CachedData {
Object data;
// 保证线程可见
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// 申请写锁之前必须先释放读锁,否则会出现死锁
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// 采用了双端检索机制,double check
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 释放写锁之前申请读锁可以将写锁降级为读锁
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // 释放写锁,依旧持有读锁
}
}
try {
use(data);
} finally {
// 释放读锁
rwl.readLock().unlock();
}
}
}
5.8、StampedLock(邮戳锁、票据锁)
StampedLock和ReentrantLock使用类似,但其特点是读操作时可以不阻塞写操作,所以读取效率比ReentrantLock更高。
StampedLock内部维护了一个版本号(stamp),通过版本号来判断锁状态以及锁是否可用。在释放锁或转换锁时都需要传入加锁时获得的stamp(邮戳,版本号)。
出现原因
为了缓解锁饥饿问题(很多个读线程,少量写线程,可能出现某些写线程迟迟拿不到锁而阻塞的问题,因为读写互斥)。
解决办法
1、排队,FIFO,使用公平锁new ReentrantLock(true),相对于非公平锁会造成大量线程切换,降低了吞吐量。
2、使用StampedLock,以乐观的心态来读取,同时读取时可以允许写操作,但是在读取时要检查资源是否被修改,如果被修改了再升级为悲观读,也就是读写互斥。
ReentrantReadWriteLock
允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,
读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统的synchronized速度要快很多,
原因就是在于ReentrantReadWriteLock支持读并发
StampedLock
ReentrantReadWriteLock的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。
但是,StampedLock采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,
所以,在获取乐观读锁后,还需要对结果进行校验。
StampedLock三种访问模式
-
Reading(读模式):功能和ReentrantReadWriteLock的读锁类似
-
Writing(写模式):功能和ReentrantReadWriteLock的写锁类似
-
Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,
支持读写并发,很乐观认为读取时没人修改,假如被修改再实现升级为悲观读模式
例子:
public class StampedLockTest {
private final StampedLock sl = new StampedLock();
int number = 20;
public void write() {
long stamp = sl.writeLock();
try {
System.out.println("我是" + Thread.currentThread().getName() + ",我要开始动手脚了");
number = number + 12;
} finally {
sl.unlockWrite(stamp);
}
}
public void read() {
long stamp = sl.tryOptimisticRead();
int result = number;
System.out.println("我认为没有被修改,当前验证后的结果:" + sl.validate(stamp));
try {
for (int i = 0; i < 5; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("当前" + Thread.currentThread().getName()
+ "读取中...,过了" + (i + 1) + "秒,当前验证没有被修改的状态为:"
+ sl.validate(stamp) + " ,当前result: " + result);
if (!sl.validate(stamp)) {
stamp = sl.readLock();
System.out.println("有线程对结果动了手脚,升级为悲观读锁,排斥写锁中。。。");
result = number;
System.out.println("通过悲观读获取到result为 " + result);
}
}
System.out.println(Thread.currentThread().getName() + "\t finally value: " + result);
} finally {
sl.unlockRead(stamp);
}
}
public static void main(String[] args) {
StampedLockTest stampedLockTest = new StampedLockTest();
new Thread(() -> {
stampedLockTest.read();
}, "readt").start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
stampedLockTest.write();
}, "writet").start();
}
}
StampedLock的缺点
1、不支持重入,线程如果持有了锁未释放,尝试再次获取锁就会造成死锁。
2、不支持条件变量,这使得在使用 StampedLock 时无法实现线程的等待和通知机制。
3、不支持中断interrupt(),因为它的乐观锁实现是基于CAS的,这种方式不支持中断操作。如果必须要响应中断可以使用readLockInterruptibly, writeLockInterruptibly来实现。
5.9、小总结
以上还有synchronized的锁升级过程未列出,从无锁 => 偏向锁 => 轻量级锁 => 重量级锁,后面笔记会继续跟进。
以上的各种锁可以看出逐渐的想要在保证线程安全的情况下尽可能的提高吞吐量,整个一个发展历程为:无锁 => 独占锁 => 读写锁 => 邮戳锁。