【多线程】(进阶)

本文深入探讨Java多线程的锁策略,包括乐观锁、悲观锁、读写锁、轻量级锁和重量级锁等,以及CAS操作和synchronized的原理。文章还介绍了可重入锁、公平锁和非公平锁的概念,并讨论了JUC中的常见类,如ReentrantLock、原子类、线程池、信号量和CountDownLatch等,帮助读者理解并掌握多线程中的并发控制和同步机制。
摘要由CSDN通过智能技术生成

多线程(进阶)

常见锁策略

锁策略是不仅仅局限于java的,这些锁策略是给锁的实现者用来参考的,但是我们也是有必要了解一下的

❤️锁策略具体查看小林coding

1乐观锁和悲观锁

乐观锁:==预测接下来锁冲突的概率不大,所以先不加锁,只有当数据真正提交更新时再验证这段时间内有没有发生冲突,如果没有其他线程修改资源(即没发生冲突),那么操作完成,如果发现有其他线程已经修改过资源了(即发生了冲突),则返回错误信息,让用户决定如何去做。==虽然发生冲突的成本比较高,但是如果发生冲突的概率比较低,还是可以接收的。乐观锁全程没有加锁,所以也叫无锁编程

举个栗子:在线文档是可以同时多人编辑的。如果使用了悲观锁,那一个用户打开了文档,其他人就无法打开文档了,用户体验是不好的。那多人编辑其实是使用的乐观锁的,允许多人同时编辑,但是编辑完提价后才验证修改的内容是否产生了冲突,那什么算冲突呢?

比如A和B都去修改文档了,A修改时,然后B也去修改,B修改完提交了,然后A修改完也去提交,这时发现A和B修改了同一个数据,这就会产生冲突。那服务器端如何去验证冲突呢?使用版本号可以去验证是否发生冲突了。

浏览器下载文档时会记录下服务端返回的文档版本号,然后用户编辑文档,当用户提价修改时,发给服务端的请求会带上下载文档时保留的版本号,服务器收到后会比较这个版本号和当前版本号是否一致,如果版本号一致则修改成功,否则提交失败。要注意:一旦提交失败需要返回错误,让用户来做决定,所以成本比较高,只有在冲突概率非常低的情况下,才考虑使用乐观锁。

乐观锁的实现可以使用CAS去比较版本号。

悲观锁:预测接下来锁冲突的概率大,所以访问资源前,先加上锁,访问结束后再释放锁

乐观锁和悲观锁的区分是:依据接下来发生锁冲突的概率是否大

synchronized是一个自适应锁,一开始是乐观锁,当发现锁竞争概率大时,自动切换成悲观锁。以乐观锁方式执行,往往是纯用户态操作;以悲观锁方式执行,一般要进入内核态操作。==因为以乐观锁方式执行时,先不加锁,只有在数据提交更新时,才会真正对数据是否产生并发冲突进行检测,;==而以悲观锁方式执行时,就会先加上锁,再执行操作。

image-20220813065847090

✅在java中可以使用synchronized加锁是因为cpu提供了原子操作指令,操作系统基于cpu提供的原子操作指令实现了mutex互斥锁,而jvm又基于操作系统实现了synchronized加锁操作,

✅所以synchronized以悲观锁的方式运行的话,必须要依赖操作系统的mutex锁,所以必然涉及到内核态的操作,而一旦涉及到内核态的操作,执行效率就会降低,因为内核态本身就不如用户态执行效率高,而且还要涉及到用户态和内核态的切换。当synchronized以乐观锁的方式运行时,就尽量避免mutex互斥锁,避免了内核态操作,纯用户态操作,所以效率更高】

✅乐观锁和悲观锁有各自的使用场景,如过锁冲突的概率大,就适合使用悲观锁,使用乐观锁会消耗额外的不必要的资源;如果锁冲突的的概率小,就适合使用乐观锁,使用悲观锁会效率降低

2普通互斥锁和读写锁

普通互斥锁:两个加锁操作会产生竞争,竞争失败的线程会释放CPU,阻塞等待。

读写锁:读写锁把锁细化了,分为加读锁和加写锁,所以针对两个线程加锁有下面三种情况:

1️⃣情况一:线程A加写锁,线程B加写锁,这种情况和普通互斥锁没有区别,两个线程会产生竞争

2️⃣情况二:线程A加读锁,线程B加读锁,这种情况不会产生锁竞争,两个线程可以并发执行,和没加锁效果一样。因为读不涉及修改,是线程安全的。

3️⃣情况三:线程A加读锁,线程B加写锁,这种情况,两个线程产生竞争,和普通互斥锁没啥去别

如果只读取共享资源用读锁加锁,如果只修改共享资源用写锁加锁,所以,读写锁使用于能明确区分是读操作还是写操作的场景。只有两个读锁才不会产生锁竞争,因为只是读取数据不会产生线程安全问题

✅读写锁适用于能够明确区分是读操作还是写操作的场景,并且特别适用于频繁读取数据的场景。

在java官方库中实现了读写锁:ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁。ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供lock/ unlock 方法进行加锁解锁 (ReadLock 和WriteLock是ReentrantReadWriteLock的内部类)

使用读写锁不仅能保证线程安全,还能提高效率(读写锁特别适合频繁读不频繁写的场景)

3轻量级锁和重量级锁

轻量级锁:加锁造成的时间开销少,做的操作少,尽量避免使用操作系统提供的锁,尽量在用户态完成功能,尽量避免用户态和内核态的切换。

重量级锁:加锁造成的时间开销大,做的操作多,主要依赖了操作系统提供的锁,使用这种锁容易产生阻塞等待

重量级锁和轻量级锁的区分是:依据加锁造成的时间开销是否高。

悲观锁经常是重量级锁,乐观锁经常是轻量级锁,synchronized是自适应锁,既可以是轻量级锁,又可以是重量级锁。具体是哪个锁,得看发生锁冲突的概率高不高,概率较高就可能是重量级锁,概率不高就是轻量级锁

image-20220813065847090

✅jvm提供的synchronized的实现依赖操作系统提供的mutex锁,mutex锁的实现依赖了cpu的原子操作指令。如果是重量级锁得依赖操作系统提供的mutex互斥锁,而轻量级锁主要是用户态代码来完成,所以使用重量级锁得执行内核态操作,还要有用户态和内核态的切换,时间开销比较高,使用轻量级锁的时间开销相对较少。

4自旋锁和互斥锁

自旋锁和互斥锁是两种比较基本的锁,很多高级的锁都是根据他俩来实现的,并且他俩都是悲观锁

❤️自旋锁和互斥锁以及读写锁都是悲观锁

自旋锁:是轻量级锁的具体实现,自旋锁是轻量级锁

互斥锁:是重量级锁的具体实现,互斥锁是重量级锁

//自旋锁伪代码,一直尝试获取锁
while(抢锁(lock)==失败){}

✅自旋锁:当发生锁冲突时,不会阻塞等待,而是立即循环尝试获取锁(不释放CPU,直到拿到锁)。优点:一旦锁释放,就能第一时间获取到,缺点:因为一直在尝试获取锁,如果长时间获取不到锁,就会消耗大量的cpu

✅互斥锁:当发生锁冲突时,会阻塞等待(释放CPU给其他线程使用)。特点:一旦其他线程释放锁,不能第一时间获取到;当锁被其他线程占有时,会放弃cpu资源

❤️互斥锁加锁是由操作系统提供的mutex实现的,互斥锁如果加锁失败也会由系统内核将当前线程设为阻塞状态(放弃了CPU),所以互斥锁加锁失败会从用户态陷入到内核态。不管是阻塞等待被切换下CPU,还是后续拿到锁之后切换上CPU,都是系统内核帮我们切换的。但是这个切换是有时间开销的,开销主要来源于两次线程的上下文切换,如果被锁住的代码执行时间比较短,那可能上下文切换带来的时间开销比被锁住的代码的执行时间还要长,所以这种情况就比较适合用自旋锁,在CPU上稍微等一会就拿到锁了,没必要下CPU又上CPU,造成更大的时间开销。

❤️自旋锁是依赖CAS实现的,自旋锁加锁的过程,包含两个步骤:1️⃣查看锁的状态(即锁有没有被别的线程获取到),如果锁是空闲的则执行第二步,如果锁不是空闲的,则循环查看锁的状态,直到获取到锁。2️⃣将锁设为当前线程持有。自旋锁加锁失败的线程会忙等待,直到拿到锁。如果被锁住的代码执行时间过长,则自旋的线程会长时间占用CPU资源,所以这种情况适合用互斥锁,释放CPU资源给其他线程。

当加锁失败时,互斥锁用切换线程来应对,而自旋锁用忙等待来应对。他俩是锁的最基本的处理方式,更高级的锁都会选择其中一种来实现。

两种锁对比:自旋锁是悲观锁,互斥锁也是悲观锁,自旋锁是纯用户态代码,更轻量,互斥锁加锁依赖系统内核提供的锁,加锁失败也是由系统内核切换线程,涉及到内核态操作,更重量。如果锁的粒度大,一般适合用互斥锁,如果锁的粒度小,适合用自旋锁。

synchronized作为轻量级锁的时候,内部实现是自旋锁;synchronized作为重量级锁时,内部实现是互斥锁

5公平锁和非公平锁

公平锁:依据先来后到的原则:比如三个线程ABC,A先获取到锁,然后B尝试获取锁,阻塞等待,然后C尝试锁,也阻塞等待,那当A释放锁后,就应该是B获取到锁,然后才是C获取到锁

非公平锁:不依据先来后到的原则,A释放锁后,B和C都有可能获取到锁

操作系统本身提供的锁是非公平锁,所以synchronized也就是一个非公平锁,如果要实现公平锁需要依赖额外的数据结构记录线程的顺序。

6可重入锁和不可重入锁

可重入锁和不可重入锁是针对同一线程说的,如果不同线程针对同一个对象加锁那就会发生锁冲突

public class Demo19 {
    public static void main(String[] args) {
        synchronized (Demo19.class){
            synchronized (Demo19.class){
                System.out.println("哈哈");
            }
        }
    }
}

按照我们之前的理解,上面代码中在同一线程中针对同一对象加锁,第一个synchronized语句先加了锁,然后第二个synchronized语句又要加锁,而第二个synchronized语句要想加锁又得等第一个synchronized语句执行完释放锁,而第一个synchronized语句执行完释放锁又得等第二个synchronized语句加上锁执行完 ,这就会产生死锁

但其实synchronized是一个可重入锁:在一个线程中对同一个对象加锁多次也不会产生阻塞

可重入锁的实现:在内部记录一下,这个锁是哪个线程得到的,如果发现当前要加锁的线程和持有锁的线程是同一个线程,那就不会产生阻塞。并且在锁的内部有一个计数器,记录加锁次数,每执行一次同步代码块次数加一,执行完同步代码块则次数减一,当次数为0才会真正的释放锁

CAS

什么是CAS

CAS是相比与synchronized更轻量的实现原子性的指令。

✅我们之前为了实现原子操作,需要加锁,使用synchronized,现在操作系统/硬件给JVM提供了一个更轻量的原子操作的机制:CAS(compare and swap),CAS是cpu的一个特殊指令,这是一条指令,所以这个指令是原子的。(比较的是内存和寄存器中的值,如果两个值相等,就把内存中的值和另一个寄存器中的值进行交换,如果不相等,则不做任何操作)

boolean CAS(address, expectValue, swapValue) {
    if (&address == expectedValue) { //&address代表内存中的值,expectedValue代表寄存器中用来比较的值
        &address = swapValue;//swapValue代表寄存器中用来交换的值,这里是赋值,并没有交换,但是其实都可以,把寄存器中的值放到内存中就行了
        return true;
    }
    //如果不相等,则什么都不做
    return false;
}

上面是一段伪代码,代表了CAS这条指令,虽然是多行代码,但是实际上表征的是CPU中的一条指令

CAS实现原子类

原子类是java标准库中提供的一些类,可以用来原子的++ ,–等操作

我们之前在多线程环境下对一个整数++操作时,需要使用synchronized加锁以保证原子性,但是java标准库基于CAS实现了原子类,也可以保证原子性,这种方式更轻量

public class Demo19 {
    public static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (Demo19.class){
                    a++;
                }
            }
        });
        Thread thread2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (Demo19.class){
                    a++;
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(a);
    }
}

上面的代码是加锁然后保证了原子性,下面代码使用基于CAS实现的原子类保证了原子性

package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class Demo19 {
    public static AtomicInteger a  = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                a.getAndIncrement(); //效果就是a++
            }
        });
        Thread thread2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (Demo19.class){
                    a.getAndIncrement();
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(a);
    }
}
  • 上面这样使用原子类,也可以保证原子性修改,最终结果是100000

下面具体说明一下这个原子类是怎么基于CAS实现原子性的操作的:

//这是AtomicInteger这个原子类的部分伪代码
class AtomicInteger {
	private int value;
	public int getAndIncrement() {
		int oldValue = value;//把内存中的值读到寄存器中,oldValue代表寄存器中的值
		while ( CAS(value, oldValue, oldValue+1) != true) {
            //比较内存中的值和寄存器中的值是否相等,相等,就把oldValue+1(寄存器中的值+1)赋值给value(内存中),不相等就把内存中的值赋值给寄存器,然后再执行CAS操作。
			oldValue = value;
		}
		return oldValue;
	}
}

image-20220813205834818

  • 两个线程在执行getAndIncrement()方法时,先把内存中的值拿到寄存器中,然后各自执行CAS操作。

✅假设内存中的value值最初是0,两个线程并行执行。然后线程1从内存读到寄存器中的值是0,线程2读到寄存器中的值也是0,既然CAS对应CPU中的一条指令,那这条指令就是一个原子操作,所以在两个线程上是有明确的先后关系的,在时间线上不会交叉。当线程1先执行CAS指令时,一比较发现相等,就把1放到了内存中,然后线程2执行CAS指令时,一比较发现不相等了,就会把内存中的值重新读取到寄存器中,然后线程2再接着执行CAS指令,再接着看寄存器和内存是否相等,相等就把寄存器中的值更新到内存中,不相等就把内存中的值重新读取到寄存器中 。就这样循环判断,哪一次一比较相等了就把寄存器中的值放到内存中,不相等就重新读取内存中的值到寄存器中。

​ ✅CAS实现原子类其实是乐观锁的体现

✅通过这样的方式能实现原子操作主要是由于:1️⃣读取数据到寄存器中是线程安全的 2️⃣CAS是一条指令,是一个原子的操作,线程1 在比较赋值时,线程2没有在比较赋值,线程2在比较赋值时,线程1没有在比较赋值,所以时间线上没有交叉重合。所以整体上是线程安全的。

CAS实现自旋锁

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有.
        // 如果这个锁已经被别的线程持有, 那么就自旋等待.
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
        while(!CAS(this.owner, null, Thread.currentThread())){
            
    	}
    }
    public void unlock (){
    	this.owner = null;
    }
}

✅上面是自旋锁的伪代码,owner代表锁的持有线程。CAS指令中比较owner和null是否一样,一样的话就表示自旋锁还没有被其他线程占有,然后就把当前线程的实例赋值给owner,循环结束,加锁成功。如果比较owner发现和null不一样,则继续循环 (自旋状态),直到别的线程释放锁,然后当前线程拿到锁,循环结束,加锁成功。

CAS中的ABA问题

假如现在有两个线程t1和t2要修改同一个值A,t1线程想使用CAS修改这个值,就需要1️⃣先把这个值读取到寄存器中,然后2️⃣使用CAS比较寄存器中的值和内存中的值是否相等,相等的话就修改这个共享的值。

但是这两个操作它不是原子的,那么在这两个操作之间,可能就会有线程t2把数据A修改为B,然后又修改为A,那么t1线程就不知道这个A到底一直没被改动过,还是中间经过改动,最后又变为了A。这就是ABA问题,大部分情况下ABA问题不会产生BUG,但有些时候ABA问题也会产生BUG。

ABA引发的BUG

假如现在有个同学去ATM机取500块钱(本来有1000块钱),取钱这个过程用的是CAS指令,但是这个同学手一滑点了两次取钱操作,就创建了两个线程。但是虽然点了两次,也应该只取出来一次钱是合理的。

image-20220815170819391

按上面的方式虽然产生了两个线程,但是只有一个线程会真的修改存款为500,但是假如这时候又有个人恰好给这位同学转了500块钱

image-20220815171458371

上图中恰好线程3在线程2执行CAS之前,让存款又变为了1000,导致线程2扣款成功,扣款两次,这就是由ABA问题引发的BUG

解决ABA问题引发的BUG

导致ABA问题主要是由于金额可加可减,这就能导致数据由A变为B再变为A。解决ABA问题的一个办法就是引入版本号,每次读取数据的时候,也要读取版本号,并且修改数据成功的话,也要把版本号加1

image-20220815173646181

✅读的时候读一个版本,等CAS比较的时候不再计较金额了,而是比较版本,如果版本一样则说明在读数据和CAS之间的时间段内,数据没有被修改,那么当前线程就可以修改数据,并且让版本号加1。如果一比较发现版本号不一样,则说明当前线程在读数据和执行CAS之间的时间段内有别的线程修改了数据,那当前线程就不做任何操作。

synchronized原理

基本特点

synchronized使用的锁策略:

1️⃣synchronized开始是乐观锁,如果锁冲突概率比较大就转换为悲观锁(自适应锁)

2️⃣synchronized开始是轻量级锁,如果锁被持有的时间较长就转换为重量级锁(自适应锁)

3️⃣轻量级锁基于自旋锁实现,重量级锁基于挂起等待锁实现

4️⃣synchronized不是读写锁

5️⃣synchronized不是公平锁

6️⃣synchronized是可重入锁

加锁过程(锁升级)

上面提到synchronized是一个自适应锁,那他是怎么进行自适应的,这是依赖锁升级这个过程:

synchronized在加锁过程中要经历下面这几个阶段:

1️⃣无锁(没加锁)

2️⃣偏向锁(刚开始加锁但还没有产生锁竞争的时候)

3️⃣轻量级锁(产生锁竞争)

4️⃣重量级锁(锁竞争更加激烈了)

✅这几个过程在执行代码时也不一定就能走完,可能走到某个锁的时候,锁就没必要再升级了

  • 偏向锁不是真正的加锁,而是用来一个标记,表示这个锁时我的了,在遇到其他线程来竞争之前,一直保持这个状态,等其他线程来竞争锁的时候,才真正加锁。这就类似于单例模式中的懒汉模式,一开始不创建实例,等真正需要的时候再创建实例,有利于节省开销。偏向锁存在的意义在于如果没有线程来竞争锁,就没必要加锁,节省不必要的开销(加锁释放锁也是有开销的)
  • 一旦有其他线程来竞争锁,立即从偏向锁转换为轻量级锁,即自旋的方式,让另一个线程一直尝试获取锁
  • 如果发现锁冲突的时间比较长或者说锁竞争比较激烈,就由轻量级锁转换为重量级锁,让另一个线程挂起等待,不要再一直以自旋的方式尝试获取锁了,因为短时间也拿不到锁,不要再浪费CPU资源了。

锁消除

synchronized除了锁升级,还有别的优化操作,那就是锁消除

锁消除:编译器+JVM自动判定认为这个代码没有必要加锁了,就自动把锁消除了

public class Demo20 {
    public static void main(String[] args) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append('a');
        stringBuffer.append('b');
        stringBuffer.append('c');
        stringBuffer.append('d');
    }
}

上面代码中的StringBuffer是线程安全的,因为里面的方法加了synchronized。上面的代码是在单线程环境下执行,所以是没必要加锁的,所以JVM可能就自动把锁消除了。

这个锁消除的情况大部分情况下是不会触发的

锁粗化

✅锁粗化是针对锁的粒度来说的,锁的粒度:synchronized包含的代码范围是大还是小,范围越大,粒度越大,范围越小,粒度越小。

image-20220814204730940

上图中在for循环里加锁,锁的粒度就比较细,在for循环外面加锁,锁的力度就比较粗

在for循环里面加锁,会导致频繁的加锁解锁,而加锁解锁又是有一定的时间开销的,所以就可能被优化成只加一次锁,锁的范围扩大了,锁的粒度粗了也就是锁粗化。

Callable接口

之前在描述任务时用的是Runnable接口,里面的run()方法的返回类型是void,这就有一些限制,假如在A线程运行run()方法产生了一个数据,而在B线程想获取到这个数据,这就比较麻烦,得依靠一个中间值,比如下面这种写法,比较繁琐,容易出错。

package thread;

public class Demo22 {
   static class Result{
       public int res;
   }
    public static void main(String[] args) throws InterruptedException {
       Result result = new Result();
        Thread thread = new Thread(()->{
           int sum = 0;
            for (int i = 0; i < 100; i++) {
                sum += i;
            }
            synchronized (result){
                result.res = sum;
                result.notify();
            }
        });
        thread.start();
        int ret = 0;
        synchronized (result){
            if(result.res == 0){
                result.wait();
            }
            ret = result.res;
        }
        System.out.println(ret);
    }
}

所以为了避免这种问题,==java中还提供了一个Callable接口,这个接口和Runnable接口一样,也是用来描述一个任务,不同点在于这个接口中的方法是有返回值的。==这是一个泛型接口,也是一个函数式接口,这个接口中的方法是可以返回相应类型的数据的

image-20220816182622142

下面来看这个接口的具体使用:

package thread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo21 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                return 10;
            }
        };
        FutureTask<Integer> task = new FutureTask<Integer>(callable);
        Thread thread = new Thread(task);
        thread.start();
        int ret = task.get();
        System.out.println(ret);
    }
}

注意:Thread的构造方法没有提供参数为Callable实例的构造方法,而是在外面使用FutureTask套了一层,套了一层就可以使用get()方法获取到返回值了。

✅当线程thread还没有执行完时,调用get()方法会阻塞,直到任务线程执行完了,get()方法才能返回,返回的值就是call()方法返回的值。

到这里总结一下创建线程的几种方式:

1️⃣继承Thread类

2️⃣使用Runnable接口

3️⃣使用lambda表达式

4️⃣使用Callable接口

5️⃣使用线程池

JUC中的常见类

JUC:java.util.concurrent

ReentrantLock

这是一个用来加锁的类,是一个可重入锁,reentrant这个单词的意思就是可重入

ReentrantLock和synchronized之间的区别:

1️⃣synchronized是一个关键字,是在JVM中实现的,以代码块为单位加锁解锁;ReentrantLock是java官方库中的一个类,使用lock()方法加锁,使用unlock()方法解锁,加锁和解锁的对象是ReentrantLock实例。

❤️因为ReentrantLock是一个可重入锁,所以如果锁被当前线程持有,再执行lock()会让锁定计数加一,如果锁被其他线程持有,再调用lock()会阻塞等待,但是和synchronized的阻塞状态还不太一样,使用synchronized竞争锁产生的状态为BLOCKING。而调用lock()方法产生的阻塞状态为WAITING

package thread;

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Demo23 {
    public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();
        
        Thread thread = new Thread(()->{
            try {
                locker.lock();
                //代码逻辑
            }finally {
                locker.unlock();
            }
        });   
    }
}

2️⃣synchronized是一个非公平锁,而ReentrantLock会提供一个公平锁版本,再构造方法里加上true这个参数就可以实现公平锁这个版本,不加默认是非公平锁版本

3️⃣ReentrantLock还提供了一个tryLock()方法,这个方法和lock()的区别在于:如果加锁失败,lock()会直接阻塞等待,直到拿到锁再往下执行,而tryLock()方法不阻塞等待,而是立即返回false,接着往下执行,也就是没加上锁就不加了,接着往下执行。

package thread;

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Demo23 {
    public static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock locker = new ReentrantLock();

        Thread thread = new Thread(()->{
            try {
                locker.lock();
                for (int i = 0; i < 50000; i++) {
                    a++;
                }
            }finally {
                locker.unlock();
            }
        });

        Thread thread1 = new Thread(()->{
            boolean flg = false;
            try {
                flg = locker.tryLock();
                for (int i = 0; i < 50000; i++) {
                    a++;
                }
            }finally {
                if (flg == true){
                    locker.unlock();
                }
            }
        });

        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(a);
    }
}

❤️就比如上面代码,thread1执行时到tryLock()时大概率加不上锁,那么就会返回false,接着执行自增的代码,所以自然就产生了线程安全问题,最后的结果也就不是100000。当执行到unlock()时我们得用lock()方法的返回值判定一下到底有没有获取到锁,只有获取到锁了才需要释放,否则会产生异常。除此之外,tryLock()还能设定等待时间,等待时间内要是没获取到锁就不再等待了,接着往下执行。

4️⃣ReentrantLock提供了更强大的等待唤醒机制,synchronized搭配的是wait(), notify()。唤醒是随机唤醒一个线程,而ReentrantLock搭配了Condition类来实现等待唤醒,可以随机唤醒,也可以指定线程唤醒。

原子类

原子类比如之前用过的AtomicInteger就是在JUC中的,原子类是基于CAS,使用乐观锁的思想实现的(使用乐观锁也叫无锁编程),比真正加锁效率高。

线程池

package thread;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo24 {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        threadPool.submit(()->{
            System.out.println("啊哈哈");
        });
    }
}

线程池的创建一般使用上面这种方式,使用Executors类中的工厂方法,这些工厂方法就封装了ThreadPoolExecutor类中的构造方法,直接使用构造方法创建线程池不太容易,为了简化就封装了一层工厂方法。ThreadPoolExecutor这个类是线程池最基本的类。下面我们看一下ThreadPoolExecutor这个类的构造 方法的具体使用:

image-20220817112833221

这是其中的一个构造方法,下面具体介绍一下这里面的参数:

1️⃣corePoolSize:核心线程数

2️⃣maximumPoolSize:最大线程数(包含了核心线程和临时线程)

❤️核心线程:不管线程是否空闲,始终存在;临时线程:繁忙的时候创建,不繁忙的时候销毁。

3️⃣keepAliveTime:允许临时线程最大空闲时间

4️⃣unit:时间单位

❤️给临时线程设置了最大空闲时间,如果超过这个时间,临时线程就会被销毁

5️⃣BlockingQueue workQueue :任务队列,之前自己实现的线程池内置了任务队列,但是也可以传入一个任务队列

6️⃣ThreadFactory threadFactory:描述了线程创建方式,比如是用一个创建一个还是一下直接创建好

7️⃣RejectedExecutionHandler handler:拒绝策略:当任务队列满了再添加任务线程池该怎么做:

  1. 超过负荷直接抛异常
  2. 交给任务的调用者来处理
  3. 丢弃任务队列中最老的任务
  4. 丢弃任务队列中最新的任务

❤️面试问题:当使用线程池创建线程时,创建几个线程合适?

这个问题回答具体数字肯定是错误的,因为得根据实际场景实际程序来判断,具体判断需要用到性能测试(压测)的方法:针对当前程序,分别设置不同数目的线程,记录程序的运行时间,CPU占用,内存占用,根据压测结果,来确定适合使用多少个线程。

程序可以分两种:

  1. CPU密集型:每个线程都要吃CPU,IO在很短时间内就可以完成,主要是CPU要做很多运算,那这时候线程数最多设置为CPU核心数就行了(设置耿多线程也不会提高效率)
  2. IO密集型:大部分是状况是CPU在等待IO,CPU使用率较低,大部分时间在做IO操作,那这时候就可以多启用一些线程,当线程在进行IO操作,没有占用CPU时,就可以让别的线程上CPU执行,充分发挥CPU的性能。

在实际开发中,一个程序中可能既要吃CPU,又要等待IO,这两者不同比例,就影响到线程数的设置了。这时候就要做压测来确定使用多少线程合适了

信号量 Semaphore

信号量本质上是一个计数器,描述了可用资源的个数。

比如说有一个停车场(停车场有固定的车位数),当有车开进去的时候,车位数就减一(申请一个可用资源,信号量就加一,称为P操作),当有车开出来的时候,车位数就加一(释放一个可用资源,信号量就减一,称为V操作)。当停车场满了就不能再入车了。(当资源申请完了,再尝试申请就会阻塞等待,直到其他线程释放资源

package thread;

import java.util.concurrent.Semaphore;

public class Demo35 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(3);

        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        semaphore.acquire();
        System.out.println("申请资源");
        //资源被申请完了,再申请就会阻塞
        semaphore.acquire();
    }
}
  • 资源数在创建信号量对象时,已经确定了,当资源被申请完了,还要申请资源就会阻塞等待(WAITING状态),直到有别的线程释放了资源。(这一点和锁其实挺像的,其实信号量就是更广义的锁,当信号量的取值只有0和1的时候,信号量就成了一个普通的锁
package thread;

import java.util.concurrent.Semaphore;

public class Demo35 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(3);

        semaphore.acquire(); //P操作
        System.out.println("申请资源"); 
        semaphore.release(); //V操作
        System.out.println("释放资源");
    }
}

CountDownLatch

相当于在一个大任务拆分成若干个小任务的时候,用这个来衡量这些小任务是否都执行完了

比如日常生活中进行一场跑步比赛,得等所有选手都跑到终点比赛才算结束,CountDownLatch就描述了啥时候选手都跑完

再比如下载文件(多线程下载),得多个线程都执行完,才算下载完。CountDownLatch就描述了等所有线程都执行完。

❤️代码实例:

package thread;

import java.util.concurrent.CountDownLatch;

public class Demo36 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(() ->{
                try {
                    System.out.println("haha");
                    Thread.sleep(3000);
                    //调用这个方法就代表线程逻辑主体执行完了
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        
        //如果还有线程没有执行完就阻塞等待
        latch.await();

    }
}

线程安全的集合类

这个等学了哈希表再做总结

死锁

什么是死锁?

上次的锁没有被及时释放,导致加锁加不上。上次加的锁要想释放,又要依赖这次加锁能够成功。使线程进入循环等待的状态

死锁发生的场景

1️⃣场景一:(一个线程一把锁):在一个线程中对一个对象连续加锁(如果是不可重入锁),就会发生死锁。

package thread;

public class Demo37 {
    public static void main(String[] args) {
        synchronized (Demo37.class){
            synchronized (Demo37.class){
                System.out.println("哈哈");
            }
        }
    }
}

✅上面的代码中针对同一个对象连续加锁两次,并不会产生死锁,因为synchronized是可重入锁,如果是不可重入锁的话,就会产生死锁。

2️⃣场景二:(两个线程两把锁)两个线程先各自拿了一把锁,然后又想要获得对方的锁

package thread;

public class Demo37 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() ->{
            synchronized (Demo34.class){
                synchronized (Demo37.class){
                    System.out.println("线程1");
                }
            }
        });
        Thread t2 = new Thread(() ->{
            synchronized (Demo37.class){
                synchronized (Demo34.class){
                    System.out.println("线程2");
                }
            }
        });
        t1.start();
        t2.start();

    }
}

❤️上面这种代码,有可能会发生死锁(当两个线程各自先拿到一把锁时),也有可能没有发生死锁(如果其中某个线程先拿到了两把锁就不会产生四锁了。)

✅举个栗子:A拿了一瓶醋,B拿了一瓶酱油,然后A想拿到酱油,得先把醋给B,B想拿到醋,得先把酱油给A。这就导致A和B争执不下。

3️⃣场景三:n个线程m把锁

举个栗子:哲学家就餐问题:五个哲学家(线程)围着餐桌吃饭,但只有五根筷子(锁),有可能五个哲学家同时拿了其中一根筷子,然后五个哲学家再尝试拿另一根筷子,发现拿不到了(这就发生了死锁)

如何避免死锁

死锁产生的四个必要条件:

1️⃣互斥使用,即当锁被一个线程占有时,别的线程不能占有

2️⃣不可抢占,即但锁被一个线程占有时,别的线程不能抢走锁

3️⃣请求和保持:即当锁持有者在申请其他锁时,应保持对自己已有锁资源的占有

4️⃣循环等待:线程1等待线程2,线程2等待线程1,互相等待

避免死锁:

上述四个条件中的最后一条是与编码密切相关的,也就是通过改善代码就可以避免循环等待

打破循环等待:针对多把锁进行编号1,2,3,4。约定每个线程在获取多把锁的时候,按编号从小到大获取。比如线程A想获取1,2两把锁就得先获取1,再获取2。只要所有的线程都遵守这个顺序,就不会死锁。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值