并发编程基础与原理

三、并发编程基础与原理
3.1线程基础
3.1.1 了解多线程与并发
决定程序承载量的因素:
硬件:CPU、内存、磁盘、网络

软件:最大化利用硬件资源,如线程数量、JVM内存分配大小、网络通信机制(BIO、NIO、AIO)、磁盘IO

线程数量如何提升服务端的并发数量?

并发和并行
单核心CPU也是可以支持多线程 -> CPU的时间片切换

多线程的特点:
1.异步;
2.并行。

Java中的线程
Runnable接口
Thread类
Callable/Future 带返回值的

这个工具怎么去使用
网络请求分发
文件导入
发送短信

3.1.2线程的基础
线程的生命周期

JPS
查看进程PID

jstack PId
输出当前堆栈的信息

阻塞:
WAITING
TIMED_WATING
BLOCKED
IO阻塞

线程有多少种状态
Java:6种
New状态(Java特有)、运行状态(包括就绪和运行)、WAITING状态、TIMED_WATING状态、BLOCKED状态。
操作系统层面:5种

线程的启动
.start()
实际上是调用JVM的start方法,该方法是用C++实现的;
再调用OS层面的start方法,线程本身是OS的概念。
此时再调用Java中的run方法。
线程的终止
.stop()不建议,并不清楚线程运行到哪里了,强制终止会造成数据问题。
.interrupted(),实际上是使用共享变量来控制线程,实际上也是JVM中的实现,如果线程正在阻塞,如sleep,那么会唤醒线程,并抛出异常,如果对这异常不做任何操作,那共享变量又会重置为false,线程继续运行。在异常中再次调用.interrupted(),才能使线程正常终止。

3.1.3并发编程的挑战
线程安全本质
原子性
有序性
可见性
锁(synchronized)
互斥锁的本质是什么?
共享资源

锁的类型
区别之处在于锁的范围
对象锁:同一个实例
如synchronized修饰非静态方法
如synchronized (this)

类锁:所有实例之间
如synchronized修饰静态方法
如synchronized (Demo.class)

锁的存储(对象头)
每个对象在创建实例时,会在JVM中创建一个内容,包含对象头、对象数据等信息。
其中对象头包含的内容及分布:

打印类的布局

org.openjdk.jol jol-core 0.10

96位对象头:

涉及到大端存储和小端存储
16进制(反过来): 0x 00 00 00 00 00 00 00 01
(64位)2进制: 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000 0 01
最后是01,所以是无锁状态

最后两位是00,所以是轻量级锁

锁的升级
锁会带来性能开销,如何优化?
不加锁(不加锁的情况实现线程安全)
偏向锁
代码里加了锁,但如果只有一个线程1在使用资源,并没有其他线程的情况下。锁就会升级成偏向锁。

当另一个线程2加入竞争,也会申请成为偏向锁。
1.线程1已经不使用该资源了(运行结束),申请成功,线程2单独使用资源。
2.申请失败,撤销线程1的偏向锁,锁升级为轻量级锁。
偏向锁是一次CAS。

偏向锁无法存储hashCode。如果计算了一次hashCode,那么锁无法成为偏向锁,会直接升级。

PS.乐观锁的概念
CAS():比较预期数据和原始数据是否一致,如果一致则修改,不一致则修改失败。

轻量级锁
线程1申请成为轻量级锁后,线程2也CAS,但是失败,此时就会进行多次CAS(自旋锁,因为另一个线程获得锁以及释放锁的时间是很短的,一般尝试10次自旋。自适应自旋算法:根据上一次获得锁的次数决定这次自旋时间)。
自旋锁失败后,锁膨胀,升级为重量级锁。

重量级锁
就是我们所理解的锁,会带来性能开销。
JVM的对象的定义是MarkOop。
任何MarkOop都有ObjectMonitor对象监视器,所以任何对象都可以成为锁。
线程的通信(wait/notify)
生产者->消费者场景。

3.1.5 探索线程安全性背后的本质之volatile
一个问题引发的思考
public class TestDemo {
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(()->{
int i=0;
while(!stop){
i++;
}
});
thread.start();
Thread.sleep(1000);
stop = true;
}
}

活性失败,线程会一直运行,跳不出循环。
Java会自动优化成
If(!stop){
While(true){
}
}

所以跳不出循环。

但有些操作可以使这个代码正常运行。
比如在线程内部:
System.out.println
Thread.sleep(0);
加锁Synchronized;
任何IO操作

以及对字段加volatile关键字

volatile关键字
保证可见性
hsdis工具可以生成汇编指令
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
-XX:CompileCommand=compileonly,*App.*(替换成实际运行的代码类名)

加了volatile后,发现输出的指令中,增加了lock … 指令
lock汇编指令来保证可见性问题
对CPU层面加了锁
什么是可见性
简单来说,就是线程A更改了共享变量后,线程B访问变量时,是否是修改后的值
硬件层面
CPU/内存/IO设备
CPU层面:增加了高速缓存
操作系统层面:进程、线程、CPU时间片来切换
编译器层面:更合理的利用CPU的高速缓存

CPU层面的高速缓存

高速缓存的存在,会导致缓存的一致性问题
如何解决?总线锁

总线锁
类似于线程锁,在一个CPU核心访问资源时,锁定资源,让其他CPU不能访问资源。但是这样就是同步运行,违背了多核CPU并行的目的。于是优化出现了缓存锁。
缓存锁
在缓存层面加锁,不在总线上加锁,降低锁的力度。

什么时候采用缓存锁替代总线锁?
1.CPU的架构是否支持
2.当前数据是否存在于缓存行
缓存一致性协议
MSI、MESI、MOSI…

MESI表示四种缓存的状态
Modify修改
Exclusive独占
Shared共享
Invalid失效

很难用代码取模拟CPU的缓存问题
引出了MESI的一个优化
引出了一个Store Buffer的概念

导致了指令重排序问题
executeToCpu0(){
a=1;
b=1;
//CPU层面的指令重排序,相当于
//先运行了b=1,再运行了a=1
}
executeToCpu1(){
while(b1){//返回true
assert(a
1);//返回false
}
}

内存屏障
为了解决store buffer的问题,引入了内存屏障
伪代码:
Volatile int a=0;//Volatile关键字,自动生成屏障
executeToCpu0(){
a=1;
storeMemoryBarrier();//写屏障,保证a先写入内存,不允许重排序
b=1;
}
executeToCpu1(){
while(b1){//返回true
LoadMemoryBarrier();//读屏障
assert(a
1);//返回false
}
}

读屏障Store
写屏障Load
全屏障Fence
volatile关键字通过内存屏障禁止了指令重排序
实际上前文提到加了volatile关键字后,是多了Lock指令,而不是内存屏障指令

不同CPU架构的问题,X86是强一致性架构。
Lock -> 等价于内存屏障

软件层面

JAVA内存模型是一个抽象的模型,其运行机制类似于CPU

为了解决可见性问题,防止指令重排序,禁止高速缓存
Volatile int a=0;//Volatile关键字,自动生成屏障
executeToCpu0(){
a=1;
storeload();
b=1;
}
executeToCpu1(){
while(b1){//返回true
assert(a
1);//返回false
}
}

JVM提供了软件层面的屏障,结合硬件层面彻底解决可见性问题

Volatile的原理
通过javap -v VolatileDemo.class
发现加了volatile关键字的属性,会多一个ACC_VOLATILE属性,在JVM中就会执行StoreLoad指令添加内存屏障

Happens-Before模型
程序顺序规则(as-if-serial语义)
不能改变程序的执行结果(在单线程环境下,执行的结果不变)
依赖问题,如果两个指令存在依赖关系,是不允许重排序。

void test(){
int a=1;
int b=1;
//允许先执行b=1
int c=a*b;
}

a happens-before b; b happens before c

传递性规则
a happens-before b; b happens before c -> a happens before c

volatile变量规则
Volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作
内存屏障机制来防止指令重排

1 happens-before 2 是否成立? 是 -> ?
3 happens-before 4 是否成立? 是
2 happens -before 3 ->volatile规则
1 happens-before 4 ; i=1成立.

所以i最终结果为1

监视器锁规则

start规则

Join规则

final关键字提供了内存屏障的规则

3.1.7线程基础阶段性总结和扩展
线程基础回顾
死锁/活锁
四个条件同时满足,就会产生死锁
1.互斥,共享资源X和Y只能被一个线程占用;
2.占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
3.不可抢占,其他线程不能强行抢占线程T1占有的资源;
4.循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。

如何解决死锁?
解决2、3、4这三个问题。
解决2占有且等待:可以在另一个方法中定义申请与释放资源,一次性申请所有资源,释放所有资源。
解决3不可抢占:使用
Lock fromLock = new ReentrantLock();
使用if(fromLock.tryLock())//返回true和false
解决4循环等待:
按顺序加锁,将加锁对象按hashCode排序。但是从业务逻辑上是有问题的。

Thead.join
Happens.Before模型之一
比如在mean方法中调用t1.join。目的是让t1线程运行的结果在mean线程调用join方法后可见。
使用wait/notity实现。

ThreadLocal
线程隔离机制,如
ThreadLocal local = new ThreadLocal();
每一个线程都独有一个Integer初始化值,各线程之间互不干扰。

ThreadLocal实现原理:

每一个Thread中都会有一个ThreadLocalMap用于存储ThreadLocal包装的值,类似但不同于HashMap,计算出每个ThreadLocal的hashCode并存储到ThreadLocalMap中。

key计算出的i会不会重复?
使用HASH_INCREMENT = 0x61c88647;
0x61c88647斐波那契数列

线性探测
用来解决hash冲突的一种策略
写入:找到发生冲突最近的空闲单元
查找:从发生冲突的位置,往后查找

面试题
阿里云、菜鸟在ThreadLocal这块的面试题比较多

Sleep,join/yield的区别
Sleep让线程睡眠指定时间,会释放时间片
Join,使用wait/notify实现,让线程的执行结果可见
yield让出时间片,触发系统调度,也可能重新被调度。
Sleep(0) ->也是触发一次系统调度。
Java中能够创建volatile数组吗
可以创建,Volatile对于引用可见,对于数组中的元素不具备可见性。

Ps:volatile缓存行的填充 -> 性能问题
Java中的++操作是线程安全的吗?
不是线程安全的,线程安全是保证:原子性、有序性、可见性,
++操作无法满足原子性。
线程什么时候会抛出InterruptedException()
Interrupted()去中断一个处于阻塞状态下的线程时(join/sleep/wait)
Java中Runnable和Caleable有什么区别
caleable带返回值
有T1/T2/T2三个线程,如何确保他们的执行顺序
join

Java内存模型是什么?
是一种抽象的内存模型,定义了共享内存中多线程程序对于共享内存的读写操作的规范,体现在虚拟机中把共享变量存储到内存中,以及从内存中获取共享变量的底层细节实现。通过一些规则对内存的读写操作进行约束,从而保证指令的执行正确性。解决的是CPU多级缓存、处理器优化重排序导致的可见性问题,保证并发场景下的可见性。

什么是线程安全?
原子性、有序性、可见性。
原子性:单个指令、多个指令,不允许被中断。
有序性:允许编译器和处理器对指令进行重排序。遵循happens-before原则。
可见性:如硬件层面:CPU高速缓存、指令重排序、JMM

3.2 J.U.C
3.2.1理解J.U.C中的ReentranLock
J.U.C:Java.util.Concurrent

Lock(Syncronized)
ReentrantLock(重入锁)
lock.lock;
lock.unlock;
ReentrantReadWriteLock(重入读写锁)
重入读写锁:读多写少的情况下,读和读不互斥,读和写互斥,写和写互斥
StampedLock

重入:在一个A线程已经获得了锁,没有释放锁之前,A线程再次抢占锁的时候,直接重入获得锁,而不需要释放再抢占锁。

思考锁的实现(设计思维)
锁的互斥特性->共享资源->标记(如:0无锁,1代表有锁)
没有抢占到锁的线程? -> 释放cpu资源(等待->唤醒)
等待的线程怎么存储? -> 数据结构去存储一系列等待中的线程,FIFO(等待队列)
公平和非公平(能否插队)
重入的特性(识别是否是同一个人?ThreadID)

技术方案:
volatile state=0(无锁),1代表是持有锁 -> 1代表重入
wait/notify|(需要唤醒指定线程。LockSupport.park() -> unpark(thread) unsafe类中提供的一个方法)
双向链表
逻辑层面去实现
在某一个地方存储当前获得锁的线程ID、判断下次抢占锁的线程是否为同一个

Lock源码验证
private volatile int state; //互斥资源 0
final void lock() {
//抢占互斥资源()
if(cas()){ //线程并行。
//有多个线程进入到这段代码?多个线程抢占到同一把锁.

}
//不管当前队列是否有人排队的? 临界点的情况.
if (compareAndSetState(0, 1)) //乐观锁( true / false) | 只有一个线程能够进入.
//能够进入到这个方法 , 表示无锁状态
setExclusiveOwnerThread(Thread.currentThread());//保存当前的线程
else
acquire(1);
}

public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

! tryAcquire(arg)
addWaiter 将未获得锁的线程加入到队列
acquireQueued(); 去抢占锁或者阻塞.

final boolean nonfairTryAcquire(int acquires) {
//获得当前的线程
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { //无锁
if (compareAndSetState(0, acquires)) { //cas
setExclusiveOwnerThread(current);
return true;
}
}
//判断?重入
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires; //增加state的值
if (nextc < 0) // overflow
throw new Error(“Maximum lock count exceeded”);
setState(nextc); //并不需要通过cas
return true;
}
return false;
}

源码总结:
Lock通过一个互斥资源AQS中的state,改成1,表示线程占有了资源,获得了锁;
没有获取到互斥资源的线程会加入到一个双向链表队列,并且使用LockSupport.park阻塞线程;
当原本持有锁的线程A调用unlock释放锁时,将互斥资源state改成0,会调用release唤醒队列中的线程,被唤醒的线程会成为head节点。

3.2.3 常见并发工具的使用及原理解析

Condition(阻塞/唤醒)
实现类似于wait/notify的功能
wait/notify/notifyAll = condition.await/signal/signalall

Wait -> 释放锁
condition.await -> 释放锁
并且会释放所有锁,包括重入锁,否则其他线程无法获得锁。

condition.siginal()有两种方式唤醒线程
原tail节点是CANCELLED状态
condition节点transfer到aqs队列后,通过lock.unlock()去唤醒

Condition的使用:BlockingQueue(阻塞队列)
3.2.5深入分析阻塞队列以及原子操作等并发工具

流程图:

CountDownLatcher线程控制
允许一个或多个线程等待,直到线程满足
countDown();
aWait();

CountDownLatch countDownLatch=new CountDownLatch(3);
3一定存在state
countDown()实现state–

与之前的锁不同,这里是用共享锁实现。所有阻塞的线程存放在AQS队列中,当countDown()使state=0时,会从head节点往后依次唤醒所有线程。

Semaphore(信号灯)
限流的机制,通俗的讲,就是类似停车位,同时只能有N个线程可以运行。
Semaphore.acquire()//获得令牌,没拿到令牌会阻塞。
Semaphore.release()//释放令牌。

Atomic原子操作(安全性)
如AtomicInteger i = new AtomicInteger(0);
i.getAndIncrement();
I.get()

3.2.8 ConcurrentHashMap
Hash表
ThreadLocal 就是使用的Hash表
Hash函数:MD5、SHA
通过hash函数来计算数据位置的数据结构

Hash冲突
多个不同的key通过hash函数运算之后落到同一个数组下表的位置。
解决方法:
线性探索(开放寻址法),i ->位置;;i+1;i+2;(ThreadLocal)
链式地址法(数组+链表存储,HashMap)
再hash法(通过多个hash函数)->布隆过滤器(bitMap)
建立公共溢出区

CHM的锁是放在哪里的?
JDK1.7的CHM,是分段锁的设计,将CHM分成16个Segment段(可扩容),每个段放了一个HashEntry(后来的HashTable),并对每一段单独加锁,一般来说,有数据put进来时,先进行hash计算,只有落在相同的segment段,再进行互斥,在HashTable中进行hash计算,得出最终落在HashTable中的位置。
JDK1.8的CHM则取消了分段锁设计,改为了16个Node节点链表设计(可扩容),同一个链表的查询复杂度是O(n),当Node节点数量扩容超过64,节点链表长度超过8时,会将Node链表转化成红黑树,查询复杂度为O(logn)[二分查找法]。

Put方法的第一个阶段(初始化)

final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode()); //计算 hash 值
int binCount = 0; //用来记录链表的长度
for (Node<K,V>[] tab = table;😉 { //这里其实就是自旋操作,当出现线程竞争时不
断自旋
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)//如果数组为空,则进行数组初始

tab = initTable(); //初始化数组
//通过 hash 值对应的数组下标得到第一个节点; 以 volatile 读的方式来读取 table 数
组中的元素,保证每次拿到的数据都是最新的
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该下标返回的节点为空,则直接通过 cas 将新的值封装成 node 插入即可;如
果 cas 失败,说明存在竞争,则进入下一次循环
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
点击跳转->执行到这个阶段,数据结构图
。。。

private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)//被其他线程抢占了初始化的操作,则直接让出自己的 CPU
时间片
Thread.yield(); // lost initialization race; just spin
//通过 cas 操作,将 sizeCtl 替换为-1,标识当前线程抢占到了初始化资格
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;//默认初始容量为
16
@SuppressWarnings(“unchecked”)
//初始化数组,长度为 16,或者初始化在构造 ConcurrentHashMap 的时候传入的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;//将这个数组赋值给 table
sc = n - (n >>> 2); //计算下次扩容的大小,实际就是当前容量的 0.75
倍,这里使用了右移来计算
}
} finally {
sizeCtl = sc; //设置 sizeCtl 为 sc, 如果默认是 16 的话,那么这个时候
sc=16*0.75=12
}
break;}
}
return tab;
}

Put方法的第二个阶段(hash冲突的情况)
Hash冲突时,会创建链表。

Put方法的第三个阶段(元素个数的统计和更新)
addCount();

AddCount()添加元素个数(初始化阶段)
baseCount(没有线程竞争时计数)
CounterCell[](有线程竞争时计数)
Sum = baseCount + CounterCell[].value总数

直接访问baseCount累加元素个数
找到CounterCell[]随机的某个下表位置,value = v+x ->表示记录元素个数
如果前面都失败,则进入到fullAndCount(扩容);

AddCount()添加元素个数(元素个数更新阶段)
CounterCell[]为null
已经初始化了,然后存在竞争,cas进行更新
如果cas失败,触发CounterCell扩容

扩容阶段
当元素个数大于阈值时
如果此时正在扩容,在扩容阶段进来的线程会协助扩容

扩容标记位:
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

n=16 : 32795
32795二进制 0000 0000 0000 0000 1000 0000 0001 1100
左移16位,低位变高位
1000 0000 0001 1100 0000 0000 0000 0000(第一位是1,表示是负数)
加2(为什么不+1,因为-1在之前的put方法中有特殊标记作用)
1000 0000 0001 1100 0000 0000 0000 0010
后续再有线程参与扩容时,再加1;扩容结束,低位-1。

所以在判断是否有线程在扩容时,判断是if(sr<0)

transfer(tab,nextTab)扩容方法
扩容+数据迁移
默认区间是16,每个线程负责16个长度的数据的迁移
扩容之后,hash&table.size的值也就是下表可能会发生改变,所以就出现了高低位数据迁移。
高低位
假设我们的 table 长度是 16, 二进制是【0001 0000】,减一以后的二进制是 【0000 1111】
假如某个 key 的 hash 值=9,对应的二进制是【0000 1001】,那么按照(n-1) & hash 的算法
0000 1111 & 0000 1001 =0000 1001 , 运算结果是 9
当我们扩容以后,16 变成了 32,那么(n-1)的二进制是 【0001 1111】
仍然以 hash 值=9 的二进制计算为例
0001 1111 & 0000 1001 =0000 1001 ,运算结果仍然是 9
我们换一个数字,假如某个 key 的 hash 值是 20,对应的二进制是【0001 0100】,仍然按照(n-1) & hash
算法,分别在 16 为长度和 32 位长度下的计算结果
16 位: 0000 1111 & 0001 0100=0000 0100
32 位: 0001 1111 & 0001 0100 =0001 0100
从结果来看,同样一个 hash 值,在扩容前和扩容之后,得到的下标位置是不一样的,这种情况当然是
不允许出现的,所以在扩容的时候就需要考虑,
而使用高低位的迁移方式,就是解决这个问题.
大家可以看到,16 位的结果到 32 位的结果,正好增加了 16.
比如 20 & 15=4 、20 & 31=20 ; 4-20 =16
比如 60 & 15=12 、60 & 31=28; 12-28=16
所以对于高位,直接增加扩容的长度,当下次 hash 获取数组位置的时候,可以直接定位到对应的位置。
这个地方又是一个很巧妙的设计,直接通过高低位分类以后,就使得不需要在每次扩容的时候来重新计
算 hash,极大提升了效率。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值