8/21 Java学习博客(续)
线程池中的核心线程数
它的设置主要看是你这个线程执行的代码是哪种类型
1.cpu密集型:代码里的主要逻辑是在进行算术运算/逻辑判断
2.IO密集型:代码里主要进行的是IO操作
设cpu核心数(逻辑核心数)是N,
假设我们一个线程的所有代码都是CPU密集型代码,这个时候,线程池数量不超过N(设置N就是极限)设置的比N大,这个时候也无法提高效率,CPU吃满了,此时更多的线程反而增加调度的开销。
假设一个线程的所有代码都是IO密集型,这个时候buchiCPU,此时设置的线程数,可以超过N较大的值,一个核心可以通过调度的方式并发执行嘛。
这里我插一句
对于阻塞队列来说 添加有3个基本方法
add在队列满的时候直接抛出异常 IllegalStateException 返回值是boolean
offer 在队列满时候是添加失败并且放回false 返回值也是boolean
put 则是会阻塞等待,直到队列有空间,返回值是void
对应的取出也有3个基本方法
**remove **在队列空的时候直接抛出异常 NoSuchElementException 返回值是泛型 E
poll 在队列空时候是获取失败并且返回 null 返回值也是泛型 E
take 则是会阻塞等待,直到队列有元素,返回值是泛型 E
还有 Java里Lambda/匿名内部类的变量捕获,最重要的要求就是:捕获局部变量时,这个局部变量必须是final 或”事实上的final(effectively final)
就比如
for(int i=0;i<10;i++) {
Thread t = new Thread(() -> {
System.out.println("thread" + i);
});
t.start();
}
这里要求就有变量捕获问题
不过要注意,成员变量不受这个限制
解决方案是临时变量
for(int i=0;i<10;i++) {
int id = i;
Thread t = new Thread(() -> {
System.out.println("thread" + id);
});
t.start();
}
这个内部变量每轮都是赋值 每一轮 都是事实final 所以解决了这个问题
为啥说要用临时变量解决这个捕获问题更优雅 而不是改成成员变量,不优雅??
因为i变量本身是一个局部变量,只应该在main方法中使用的.若把i变成成员变量,其他的方法也能访问到,作用域扩大了。
多线程的进阶部分
乐观锁和悲观锁
乐观锁、悲观锁不是某一把具体的锁,而是两种并发控制思想/策略。它们基于对并发冲突概率的不同假设,采取不同处理方式。
乐观锁假设冲突较少,因此通常不提前加互斥锁,而是在更新时通过 CAS、版本号等方式检查冲突;
悲观锁假设冲突较多,因此会在操作资源前先加锁,比如 synchronized、ReentrantLock。
自旋锁和挂起等待锁
自旋锁:是一种轻量级锁的典型实现。
线程拿不到锁时,不马上挂起,而是在 CPU 上循环等待,看看锁能不能很快释放。它避免了线程挂起/唤醒和上下文切换,所以通常被认为是轻量级的等待方式。
挂起等待锁:是重量级锁的一种典型实现.
线程拿不到锁后,进入阻塞状态,让出 CPU,等锁可用时再被唤醒。因为涉及阻塞、唤醒、调度、上下文切换,所以通常属于重量级的等待方式。
简要理解就是
自旋:拿不到锁但线程不睡,拿 CPU 换响应速度。
挂起:拿不到锁就睡,拿调度开销换 CPU 资源。
轻量级锁
往往是在纯用户态体现
比如使用一个while循环,不停的检查当前锁是否被释放.如果没释放,就继续循环,释放了就获取到锁,从而结束循环等,消耗cpu但是换来的是更快的响应速度)
重量级锁
要借助系统api来实现.一旦出现锁竞争了,就会在内核中触发一系列的动作
(比如让这个线程进入阻塞的状态,暂时不参与cpu调度) 阻塞的开销是很大的~~
还有就是读写锁 读加锁和写加锁
这里我就联想到了数据库的读写锁 关乎数据库,事务,隔离性
1.读未提交 可能发生脏读,就是提前读到了别的事务未提交的数据,万一事务回滚了,这个数据就成了脏数据 ,需要写加锁
写锁一直持有到事务提交,别的事务不能读未提交修改;
2.读已提交
可能发生不可重复读,同一条记录,前后读到的值变了,需要读加锁
读锁一直持有到事务结束,防止别人中途修改;
3.可重复读
可能发生幻读,同一条件前后读到的行数变了
需要范围锁/间隙锁/Next-KeyLock,防止别人往查询范围内插入数据
4.串行化
上述问题都能解决就是并发性太低了
在线程中
读写锁,是把加锁操作,分成读锁和写锁.
两个线程加锁过程中:
1.读锁和读锁之间,不会产生竞争.//多线程读取同一个数据,没有线程安全问题的.
2.读锁和写锁之间,有竞争
3.写锁和写锁之间,也有竞争.
实际开发中,遇到的场景,往往是"读多,写少"
可重入锁和不可重入锁
一个线程针对同一把锁,连续加锁两次.不会死锁,就是可重入锁.会死锁,就是不可重入锁.
公平锁和非公平锁
当很多线程去尝试加一把锁的时候,一个线程能够拿到锁,其他线程阻塞等待.
一旦第一个线程释放锁之后,接下来是哪个线程能够拿到锁呢?
公平锁:就是按照 "先来后到” 顺序.
非公平锁:则是剩下的线程以 ** "均等" ** 的概率,来重新竞争锁.
操作系统提供的加锁api默认情况,就属于"非公平锁”如果要想实现公平锁,你还需要引入额外的队列,维护这些线程的加锁顺序.
然后再复习下 synchronized
synchronized
本质:悲观互斥
竞争轻
→ 尽量走低开销路径
→ 轻量级锁 / 自旋等
竞争重
→ 锁膨胀
→ ObjectMonitor
→ 可能阻塞、挂起线程
→ 重量级
接下里就是CAS(Compare and swap)
比如有一个内存,M现在还有两个寄存器,A,B
CAS(M, A, B)
交换的本质,是为了把B赋值给M.
(寄存器B里的值是啥,我们不太关心.更关心的是M里的情况)
如果M和A的值相同的话,就把M和B里的值进行交换,同时整个操作返回true
如果M和A的值不同的话,无事发生.同时整个操作返回false
CAS 是一种基于 CPU 原子指令实现的无锁并发机制。
我们就可以使用CAS完成一些操作,进一步的替代"加锁”
基于CAS实现线程安全的方式
也称为"无锁编程"
优点:保证线程安全,同时避免阻塞.(效率)
缺点:
1.代码会更复杂,不好理解
2.只能够适合一些特定场景,不如加锁方式更普适.
还有实现原子类
int,进行++,不是原子的(load,add,save)
AtomicInteger,基于CAS的方式对int进行封装了.此时进行++,就是原子的了.基于CAS指令来实现的.
然后还学习了基于CAS实现自旋锁
import java.util.concurrent.atomic.AtomicReference;
public class SpinLock {
// 保存持有锁的线程,AtomicReference提供CAS原子操作
private final AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
// CAS:期望为null,更新为当前线程;失败就循环自旋
while (!owner.compareAndSet(null, Thread.currentThread())) {
// 自旋空循环;可以加Thread.yield()让出CPU,减少CPU狂转
}
}
public void unlock() {
// 重要:只有持有锁的线程才能解锁!
Thread current = Thread.currentThread();
if (current != owner.get()) {
throw new IllegalMonitorStateException("不是锁持有者,不能释放锁");
}
owner.set(null);
}
}
AtomicReference
有个主要问题就是 ABA问题
CAS进行操作的关键,是通过值“没有发生变化”来作为”没有其他线程穿插执行”判定依据.
但是,这种判定方式,不够严谨.
更极端的情况下,
可能有另一个线程穿插进来,把值从A->B->A
针对第一个线程来说,看起来好像是这个值,没变,但是实际上已经被穿插执行了.
假设这个场景,我去ATM取钱.我本身的账户1000
我想要取500
我在取钱的过程中,出现bug了.
我按下取钱按钮,没反应,我又按了一下.此时就产生了两个线程进行扣款操作!!!
此时t1先进行了获取要比较的值1000 然后t2比较完并扣款 这时候又来了个t3 也进行了一个存500 完成后
t1再进行比较发现还是1000就又扣款了 这时候就出现了取500扣1000的情况
解决方法就是
只要让判定的数值,按照一个方向增长即可.(不要反复横跳)有增有减,就可能出现ABA
只是增加,或者只是减少,
针对像账户余额这样的概念,本身就应该要能增能减,
可以引入一个额外的变量,版本号.
约定每次修改余额,都要让版本号自增
此时在使用CAS判定的时候,就不是直接判定余额了,而是判定版本号,看版本号是否是变化了.
如果版本号不变,注定没有线程穿插执行了.
synchronized锁升级
无锁->偏向锁(jdk15后删除)->轻量级锁->重量级锁
偏向锁仍然是一把锁,只是这把锁长期“偏向”某个线程。
无锁则根本没有线程持有这把锁。
举例子
无锁:
房间门没锁也没写任何人的名字
偏向锁:
门口写着:
“这个房间默认给A 使用”
A来:
直接进去,手续非常少。
B来:
不行,需要先处理A的"专属资格”。
所以偏向锁并不是"没有锁”,而是:
在没有竞争时,把锁的获取成本压得非常低。
不能因为它是偏向锁,就允许其他线程随便同时进入。
所以一句话总结:
偏向锁不是无锁,而是“针对单线程重复加锁场景优化到接近无锁成本的一种锁状态”。
无锁
→ 当前没人持有锁
偏向锁
→ 基本认为只有一个线程会用
轻量级锁
→ 出现多个线程,但竞争还不激烈
重量级锁
→ 竞争激烈,需要阻塞/唤醒
锁消除
一种编译器优化的手段
编译器会自动针对你当前写的加锁的代码,做出判定,如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized给优化掉
举个例子
StringBuilder 不带 synchronized
StringBuffer 带有 synchronized
如果是在单个线程中使用 StringBuffer,此时编译器就会自动的把 synchronized给优化掉
锁粗化
首先认识锁的粒度
synchronized里头,代码越多,就认为锁的粒度越粗
代码越少,锁的粒度越细.
粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用多核CPU资源
但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁.(涉及到反复的锁竞争)
总结:
锁升级:
抢的人越来越多 → 锁变重
锁消除:
根本没人跟你抢 → 锁删掉
锁粗化:
一直加锁解锁太麻烦 → 几把小锁合成一把大锁
一个新的创建线程的方法
实现callable接口并且重写call方法 (也可追加匿名内部类)
适合于,想让某个线程执行一个逻辑,并且返回结果的时候.
相比之下,Runnable不关注结果.
一个简单的callable创建
public static void main(String[] args) throws ExecutionException, InterruptedException {
//定义了任务
Callable<Integer> callable = new Callable<>(){
@Override
public Integer call() throws Exception {
int sum =0;
for(int i=1;i<=1000;i++){
sum+=i;
}
return sum;
}
};
//把任务放到线程中执行
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
//此时的get能获取到callable的返回结果
//由于线程是并发执行的,执行到主线程get时候t线程还没执行完
//没执行完的话,get就会阻塞
System.out.println(futureTask.get());
}
这里面的FutureTask其实就相当于饭店的小票 拿着小票换执行结果
/**
- Callable
- → 负责“做任务,并返回结果”
- FutureTask
- → 包装 Callable
- → 既能交给 Thread 执行
- → 又能保存执行结果
- get()
- → 获取结果
- → 如果任务没结束,就阻塞等待
*/
目前知道的线程的创建方式
1.继承Thread,重写run(创建单独的类,也可以匿名内部类)
2.实现Runnable,重写run(创建单独的类,也可以匿名内部类)
3.实现Callable,重写call(创建单独的类,也可以匿名内部类)
4.使用lambda表达式
5.ThreadFactory 线程工厂
6.线程池
ReentrantLock
ReentrantLock 也是一个可重入锁.使用效果上和 synchronized是类似.
优势:
1.ReentrantLock,在加锁的时候,有两种方式.lock,tryLock.
lock 没上锁阻塞等待 tryLock 没上锁直接放弃
给了咱们更多的可操作空间.
2.ReentrantLock,提供了公平锁的实现.(默认情况下是非公平锁)
3.ReentrantLock提供了更强大的等待通知机制.
搭配了Condition类,实现等待通知的
虽然 ReentrantLock 有上述优势,但是咱们在加锁的时候,还是首选 synchronized
但是很明显,Reentrantlock 使用更加复杂.尤其是容易忘记解锁.
ReentrantLock lock = new ReentrantLock();
lock.lock();
try{
// working
} finally {
lock.unlock()
}
需要我们自己手动释放锁
信号量 Semaphore
也是操作系统课程中,比较重要的概念.
信号量,就是一个计数器.
描述了"可用资源”的个数.
每次申请一个可用资源,就需要让计数器-1每次释放一个可用资源,就需要让计数器+1
(这里的+1和-1都是原子的)
Semaphore semaphore = new Semaphore(4);
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
semaphore.acquire();
System.out.println("P操作");
}
这是只会打印4个 因为他只有4个资源,开发中如果遇到了需要申请资源的场景,就可以使用信号量来实现了.
CountDownLatch
这个东西,主要是适用于,多个线程来完成一系列任务的时候,用来衡量任务的进度是否完成。
比如需要把一个大的任务,拆分成多个小的任务,让这些任务并发的去执行.就可以使用countDownLatch来判定说当前这些任务是否全都完成了. 其实就相当于内置了一个计数器
CountDownLatch主要有两个方法.
1.await,调用的时候就会阻塞.就会等待其他的线程完成任务.所有的线程都完成了任务之后,此时这个await才会返回,才会继续往下走.
2.countDown,告诉countDownLatch,我当前这一个子任务已经完成了.
public static void main(String[] args) throws InterruptedException {
//10个选手参赛,await会在10次调用完countDown之后才会执行,相当于内置计数器
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(() -> {
System.out.println("thread"+id);
//通知当前任务执行完毕
countDownLatch.countDown();
try {
countDownLatch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
//a=>all
countDownLatch.await();
System.out.println("所有任务完成了");
}
数据结构中大部分的集合类,都是线程不安全的.
除了个例 Vector Stack Hashtable 线程安全=> 内置synchronized
针对这些线程不安全的集合类,要想在多线程环境下使用,就需要考虑好线程安全问题了.
方法就是加锁
同时,标准库,也给我们提供了一些搭配的组件,保证线程安全,比如Collections.synchronizedList(new ArrayList);
这个东西会返回一个新的对象.这个新的对象,就相当于给 ArrayList套了一层壳.这层壳就是在方法上直接使用 synchronized的,把带synchronized 和不带synchronized的ArrayList分开 按需使用
CopyonwriteArrayList写时拷贝.
比如,两个线程使用同一个ArrayList可能会读,也可能会修改.
如果要是两个线程读,就直接读就好了.可以读取数据(从原来的数据上进行读取)
如果某个线程需要进行修改,就把ArrayList,复制出一份副本.修改线程,就修改这个副本.与此同时,另一个线程仍然读原数据
一旦这边修改完毕,就会使用修改好的这份数据,替代掉原来的数据.(往往就是一个引用赋值)
上述这个过程进行修改,就不需要加锁了.
使用这个有要求:1.当前操作的ArrayList 不能太大.(拷贝成本,不能太高)
(多个线程读,一个线程修改)
2.更适合于一个线程去修改.而不能是多个线程同时修改.
这种场景特别适合于服务器的配置更新
可以通过配置文件,来描述配置的详细内容.(本身就不会很大)
配置的内容会被读到内存中,再由其他的线程,读取这里的内容.
但是修改这个配置内容,往往只有一个线程来修改.
使用某个命令让服务器重新加载配置.就可以使用写时拷贝的方式了
ConcurrentHashMap
ConcurrentHashMap (JDK1.8):只锁当前要修改的那一个桶的头结点。不同桶并发读写互不阻塞;同一个桶才会锁竞争。空桶直接 CAS 插入。
Hashtable保证线程安全,主要就是给关键方法,加上synchronized.直接加到方法上的.(相当于给this加锁)
只要两个线程,在操作同一个Hashtable就会出现锁冲突~~
但是,实际上,对于哈希表来说,锁不一定非得这么加,有些情况,其实是不涉及到线程安全问题的~
两个不同的key映射到同一个数组下标上,就会出现hash冲突.使用链表来解决hash冲突
按照上述这样的方式来操作,并且在不考虑触发扩容的前提下,操作不同的链表的时候就是线程安全的相比之下,如果两个线程,操作的是同一个链表,才比较容易出现问题!!
也就是说如果两个线程,操作的是不同的链表,就根本不用加锁.只有说操作的同一个链表才需要加锁.
1.ConcurrentHashMap最核心的改进,就是把一个全局的大锁,改进成了每个链表独立的一把小锁.(就是把每个链表的头结点,作为锁对象.synchronized 可以使用任意对象作为锁对象)
这样做,大幅度降低了锁冲突的概率.
一个hash表,有很多这样的链表.两个线程恰好同时访问一个链表情况,本身就比较少
2.充分利用到了CAS特性.把一些不必要加锁的环节给省略加锁了.
比如,需要使用变量记录hash表中的元素个数
此时,就可以使用原子操作(CAS)修改元素个数
3.ConcurrentHashMap,还有一个激进的操作,针对读操作没有加锁.
读和读之间.读和写之间,都不会有锁竞争.
是否会存在这种"读到一个修改了一半的数值呢?"
ConcurrentHashMap在底层编码过程中,比较谨慎的处理了一些细节
修改的时候会避免使用++--这种非原子的操作
使用=进行修改,本身就是原子的.
读的时候,要么读到的是写之前的旧值,要么是读到写之后的新值.不会出现读到一个一半’
4.ConcurrentHashMap针对扩容操作,做出了单独的优化.
(如果元素很多,拷贝就比较耗时)
本身Hashtable 或者HashMap在扩容的时候,都是需要把所有的元素都拷贝一遍的
用户访问1000次,999次都很流畅,其中有一次就卡了.(正好这一次触发扩容,导致出现卡顿)
方法就是
化整为零
一旦需要扩容,确实需要搬运,不是在一次操作中搬运完成,而是分成多次,来搬运.每次只搬运一部分数据
避免这单次操作过于卡顿
注意的是ConcurrentHashMap基本的使用方法和普通的HashMap完全一样
分段锁segment
在Java8之前,ConcurrentHashMap就是基于分段锁(多个链表共用一把锁)的方式实现的.
等到从Java8开始之后,就成了直接在链表头结点,加锁的形式.

浙公网安备 33010602011771号