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 owner 它不是操作系统底层的锁对象,它是一个「原子状态容器」,用来记录:当前哪个线程持有这把自旋锁。

有个主要问题就是 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开始之后,就成了直接在链表头结点,加锁的形式.

posted @ 2026-08-30 19:06  曾章吉的浩吧  阅读(4)  评论(0)    收藏  举报