多线程进阶

目录

锁的策略

1、乐观锁和悲观锁

2、轻量级锁和重量级锁

3、自旋锁和挂起等待锁

4、普通互斥锁和读写锁

5、公平锁和非公平锁

6、可重入锁和不可重入锁

synchronized内部的工作原理

1、偏向锁阶段

2、轻量级锁阶段

3、重量级锁阶段

锁消除

锁粗化

总结:

CAS

什么是CAS

CAS伪代码

CAS与线程安全问题

实现自旋锁

CAS的ABA问题

JUC(java.util.concurrent)中的常见类

Callable接口

ReentrantLock

ReentrantLock和synchronized的区别

信号量Semaphore 

CountDownLatch

为什么要使用CountDownLatch?

线程安全的集合类

多线程环境使用ArrayList

1、程序员自己按照情况使用同步机制(synchronized 或者 ReentrantLock)

2、Collections.synchronizedList(new ArrayList)

3、使用CopyOnWriteArrayList

多线程下使用Queue

1、自己加锁

2、使用BlockQueue

多线程下使用哈希表

HashMap

Hashtable

ConcurrentHashMap


锁的策略

1、乐观锁和悲观锁

乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在加锁时,就不会做太多的工作。此时,加锁的速度可能更快,但是更容易引入一些其他的问题(消耗更多的CPU资源)。

悲观锁:在加锁之前,预估当前出现锁冲突的概率比较大,因此在进行加锁的时候,做的工作就会更多,加锁速度可能会变慢,但整个过程不容易出现其他的问题。


2、轻量级锁和重量级锁

轻量级锁:加锁开销小,加锁速度更快——一般是乐观锁。

重量级锁:加锁开销大,加锁速度更慢——一般是悲观锁。

轻量级锁和重量级锁:是加锁之后,对结果的一种评价。

乐观锁和悲观锁:是在加锁之前,对未发生的事情进行的一种评估。

整体来说,这两种概念,描述的是同一件事情。


3、自旋锁和挂起等待锁

自旋锁:是轻量级锁的一种典型实现。进行加锁的时候,搭配一个while循环,如果加锁成功,循环就结束。如果加锁不成功,不是阻塞放弃CPU,而是进行下一次循环,再次尝试获取到锁。

这个反复快速执行的过程就称为“自旋”,一旦其他线程释放了锁,就能第一时间拿到锁,同时,这样的自旋锁也是乐观锁,使用自旋锁的前提就是预期锁冲突出现的概率不大,其他线程释放了锁,就能第一时间拿到做锁。如果当前的锁冲突特别大,自旋的意义就不打了,会白白浪费CPU资源。

挂起等待锁:是重量级锁的一种典型实现,同时也是悲观锁。在挂起等待的时候就需要内核调度器介入了,所需的操作就很多了,真正获取到锁要花费的时间也就多了。这个锁适用于锁冲突激烈的情况。

举个例子:我是一个资深舔狗,每天都会向女神发早安午安晚安。有一天,我向女神表白:“女神女神,你能不能做我女票。”(尝试加锁),女神回了我一个字:“滚”!!!

被女神拒绝之后,此时,我有两种处理方式:

1、放弃,从此不再联系女神。此时,我就进入了阻塞等待,我就把CPU让出来了,可以安心学习了(但嘴上说再也不联系了,身体上还是很诚实的) 。某一天,我通过其他途径,听说女神分手了,我又情不自禁来找女神了(又尝试给女神加锁了,女神释放了锁,此时,我是有可能成功加上锁)。

上述这种策略就是挂起等待锁,这种策略加锁并不会像自旋锁加得那么快。线程一旦进入阻塞,就需要重新参与系统的调度,什么时候能够再调度上CPU就不确定了。但是这种策略的好处在于在阻塞的过程中,把CPU资源让出来了,让CPU能去完成其他的工作。

2、坚信一个道理:只要锄头挖得好,没有墙角挖不倒。依然每天向女神问候:早安午安晚安,时不时再表白一次。这种方式就是自旋锁

这种情况,一旦女神分手了,我的机会就来了,有很大的可能性,趁虚而入,一举加上锁。

加锁消耗的时间就比较短,这边一释放,我立即就加上锁。但是缺点就是比较消耗CPU,每天都得花时间和女神交流(CPU就没办法干其他事情)。

自旋锁也是乐观锁,预估锁竞争不激烈才能使用,想象一下,如果女神的备胎不止一个,有十几个备胎,也和我一样天天早安午安晚安一样问候,此时,女神就算分手,也不一定轮得到我。


4、普通互斥锁和读写锁

普通互斥锁:类似于synchronized操作会涉及到加锁和解锁

读写锁:把加锁分为两种情况:1、加“读”锁   2、加“写”锁

读锁和读锁之间不会发生锁冲突(不会阻塞),写锁和写锁之间,会出现锁冲突(会阻塞),读锁和写锁之间,会出现锁冲突(会阻塞)。

一个线程加读锁时,另一个线程,只能读,不能写。

一个线程加写锁时,另一个线程,不能写,也不能读。

为什么要引入读写锁呢?

如果两个线程读,本身线程就是安全的,不需要互斥。如果使用synchronized这种方式加锁,两个线程读,也会产生互斥,产生阻塞……这样的话完全没有必要,又会对性能产生一定的损失。

完成给读操作不加锁也不行,就怕一个线程进行读操作,另一个线程进行写操作,可能会读到写了一半的数据。

读写锁,就可以很好解决上述问题,它能把这些并发读之间锁冲突的开销给省下,对于性能的提升非常明显。

在标准库中,也提供了专门的类,实现读写锁(本质上时系统提供的读写锁,提供API,JVM中封装了API给Java程序员使用),这里暂不介绍~~


5、公平锁和非公平锁

这和前面提过的“线程饿死”有一点关系。

公平锁:遵守“先来后到”原则,谁先来的,谁就在锁释放之后最先获得 。

非公平锁:不遵守“先来后到”原则。

举个例子:

当女神和男票恋爱中,兄弟们都在当备胎等待,A 兄弟已经追女神 1 年,B 兄弟追女神 1 个月,C 兄弟昨晚上才开始追女神。

当女神分手后:公平锁的情况下,A 号大兄弟是最开始舔的,他就嗖嗖的上位追女神了,剩下两位老哥就继续等着。

非公平锁:三位大兄弟不管谁先开始舔的,对着女神就是一拥而上~~~

注意:操作系统内部的线程调度就可以视为是随机的,如果不做任何额外的限制,锁就是非公平。如果想要实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。

公平锁和非公平锁并没有好坏之分,关键还是看使用场景。


6、可重入锁和不可重入锁

可重入锁对已经上锁的线程重新加锁,不会发生死锁问题的锁。比如一个递归函数中又加锁操作,递归过程中,这个锁如果不会阻塞自己,那么这个锁就是可重入锁(因此,可重入锁也叫做递归锁。可重入锁中需要记录持有锁的线程是谁,加锁次数的计数器。 

不可重入锁:一个线程只能上一把锁,连续上锁两次,会产生死锁问题的锁。

Java里只要以Reentrant开头命名的都是可重入锁,而且JDK提供的所有线程的Lock实现类,包括synchronized都是可重入锁。

理解“把自己锁死”

一个线程没有释放锁,然后又尝试加锁

按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第二把锁,但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,扎耶不干了,也就无法进行解锁操作,这样就会死锁。 

不可重入锁:


上面的“锁策略”就是一堆的名词的解释,我们需要对这些词有概念上的认识。

对于synchronized来说:1、乐观锁/悲观锁自适应(根据当前锁冲突大小而定,锁冲突小就是乐观锁,冲突大就是悲观锁)2、轻量级锁/重量级锁自适应    3、自旋锁/挂起等待锁自适应    4、不是读写锁   5、非公平锁   6、可重入锁

对于系统原生的锁(Linux提供的mutex这个锁):1、悲观锁   2、重量级锁   3、挂起等待锁  4、不是读写锁  5、非公平锁   6、不可重入锁


synchronized内部的工作原理

synchronized内部优化是非常好的,大部分情况下,使用synchronized是不会有什么问题的。

当线程执行到synchronized的时候,如果当前这个线程处于未加锁状态,就会经历以下阶段:

1、偏向锁阶段

核心思想:懒汉模式,能不加锁,就不加锁,能晚加锁,就晚加锁。所谓偏向锁,并不是真的加锁,而是做了一个非常轻量的标记。换句话说,就是搞暧昧,偏向锁,只是做一个标记,并没有真正加锁(也不会有互斥),但如果发现有其他线程,来和我竞争这把锁,就会在另一个线程之前,先把锁获取到,从偏向锁升级为轻量级锁(真正加锁了,存在互斥了)

如果在偏向锁阶段,没有人来竞争,就把加锁这样的操作省略了。

这种”非必要,不加锁“,在遇到锁竞争的情况下,并没有提高效率;但是,如果在没有竞争的情况下就大大提高了效率。


2、轻量级锁阶段

存在锁竞争,但是锁竞争不大就会进入轻量级锁阶段。(此处通过自旋锁方式实现的)。

优势:另外的线程把锁释放了,就能第一时间拿到锁。

劣势:比较消耗CPU资源

与此同时,synchronized内部也会统计,当前锁对象上,有多少个线程在参与竞争,这里发现参与的线程比较多的时候,就会进一步升级成重量级锁(对于自旋锁来说,如果同一个锁竞争者很多,大量的线程都在自旋,整体CPU的消耗就很大了)

补充:偏向锁标记,是锁对象里面的一个属性,每个锁对象都有自己的标记,当这个锁首次被加载的时候,先进入偏向锁阶段,如果在这个阶段中,没有涉及到锁竞争,下次加锁还是先进入偏向锁,一旦这个过程中升级为轻量级锁了,后续再针对这个对象加锁,就都是轻量级锁了,跳过了偏向锁。


3、重量级锁阶段

此时拿不到所得线程就不会再继续自旋了,而是进入“阻塞等待”,让出CPU(不会让CPU的占用率太高)当线程释放锁的时候,就会由系统内核随机唤醒一个线程来获取锁了。

到底多少个线程算多呢?这是JVM源码里面的,我们要关注的重点是,会有这种“策略”,参数是可以随时调整,策略是通用。


锁消除

这也是synchronized内置的优化策略,是编译器优化的一种方式:编译器在编译代码时,如果发现这个代码不需要加锁,就会自动把锁干掉。

但这里的优化是比较保守的,比如,就只有一个线程,在这一个线程里枷锁了,或者说,加锁代码中,并没有涉及到“对成员变量的修改”,只是对一些成员变量的修改(如果加锁代码块中只涉及局部变量的修改,而没有对成员变量进行修改,也不需要加锁。这是因为局部变量是线程独有的,不会出现多个线程同时访问同一个局部变量的情况,也就不会有数据竞争问题)。是不需要加锁的。

其他模棱两可的情况,编译器也不确定,是不会消除的。

这个机制,只会针对一眼看上去就完全不涉及线程安全问题的代码,把锁消除掉 。

锁粗化

会把多个细粒度的锁,合并成一个粗粒度的锁。

锁的粒度:synchronized{  }大括号里面包含的代码越少,就认为锁的粒度越细,包含的代码越多,就会认为锁的粒度越粗。

通常情况下,让锁的粒度细一些,是有利于多个线程并发执行的,但也有些时候,希望锁的粒度粗一些。

如上图,在代码执行的过程中,设计到很多加锁解锁操作,锁的粒度比较细,每次加锁都是可能涉及到阻塞的。

如下图,编译器就会把三次细粒度的锁合并成一个粗粒度的锁了,这样就能提高了效率。 


总结:

sychronized背后涉及了很多的优化手段:

1、锁升级:偏向锁 -> 轻量级锁 -> 重量级锁。

2、锁消除:自动干掉不必要的锁。

3、锁粗化:把多个细粒度的锁合并成一个粗粒度的锁,减少锁竞争的开销。

这些机制都是在内部默默发挥作用的,是JVM的大佬们为我们默默奉献的(他暖,我哭~~~


CAS

什么是CAS

CAS:compare and swap,字面意思:“比较并交换”,是一个特殊的CPU指令(严格的说,和Java无关,它是操作系统内部)(JVM中关于CAS的API都是在unsafe包中,即不安全)。

一个CAS就会涉及到一下操作:我们假设内存中的原数据为V,寄存器中的值是A,需要修改的是新值B,会有三个操作:

1、比较原数据V和寄存器中的值是否相等(比较)

2、如果比较相等,把B写入V。(交换)

3、返回操作是否成功。

CAS伪代码

其中,address是内存地址中的值expectValue是寄存器中的旧值,swapValue是寄存器中的新值。

if语句中的判断条件是:比较address内存地址中的值,是否和expectedValue(寄存器中的旧值相同,如果相同,就把swap寄存器的值和address内存中的值,进行交换,返回true;如果不相同,则啥都不干,返回false)。

说是交换,也可以理解为“赋值”,我们往往只关注内存里最终的值,寄存器里的值用完了就不需要了。

CAS一条指令就可以完成上述的功能,单个CPU指令,本身就是原子的。 

CAS与线程安全问题

基于CAS指令,就给线程安全问题的代码,打开了一个新世界的大门!我们之前为了实现线程安全,往往都是依靠加锁来保证的,但是线程一旦加上了锁,就会导致线程阻塞,从而引起性能降低。

使用CAS,不涉及加锁,就不会导致阻塞,合理使用也是可以保证线程安全的-->无锁编程(多线程编程中的一个特殊技巧).

CAS本身的CPU指令,操作系统对指令进行了封装,JVM又对操作系统提供的API又进行了封装,有的CPU可能会不支持CAS(但我们x86这种CPU是没问题的)。

Java中的CAS的API放到了unsafe包里(这里面的操作,涉及到一些系统底层的内容,使用不当可能会带来一些风险,一般不建议直接使用CAS)

Java的标准库,对于CAS又进行了进一步封装,提供了一些工具类,供程序员使用。

最主要的工具,叫做“原子类”:

 在这个类中,就进行了一些封装,比如对Integer和Long进行了封装,针对这样的对象进行多线程修改,就是安全的了。

代码示例 :

package Thread;

import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo34 {
    //使用原子类保证线程安全
    //这个代码更高效
    //不使用原生的int而是使用AtomicInteger
    //private static int count = 0;
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //count++
                count.getAndIncrement();
                //++count
                //count.incrementAndGet();
                //加任意数
                //count.getAndAdd(10);
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("count:"+count.get());
    }
}

这个代码就是我们之前典型的线程不安全的代码进行了修改:此处,我们使用AtomicInteger定义count(此时是一个对象),初始值传入参数为0,然后在线程中使用getAndIncremment方法,代替了后置++,此时这个方法就是通过CAS的方式实现的,这里的代码没有加锁但也能保证线程安全。(并且这个代码更为高效,没有锁,也就意味着没有阻塞,也就不会损耗效率)。

之前count++是三个指令(多线程的三个指令,可能会相互穿插执行,引起线程安全问题,之前的加锁,就是为了能让三个指令变为原子的)此处的getAndIncrement对变量进行修改,是CAS指令,CAS指令本身就只是一条CPU指令,天然就是原子的。

原子类自增的源码 :

 看起来可能有点复杂,我们可以通过一段伪代码来进行理解:

在这段伪代码中,oldValue是一个期望放在寄存器里面的值,这个值就被初始化成了AtomicInteger里面保存的整数值value,如果内存地址的值value和寄存器里面的值oldValue比较相同,则可以交换,oldValue + 1 和 value交换,然后循环结束,此时value已经更新成value + 1了,如果没成功就在来一次直到成功为止。

画图理解:

最开始我们初始化value为0 :

t1线程,将value赋值给oldValue

然后调度到 t2 线程执行,t2 线程也赋值 oldValue 为 0 

然后 t2 线程进入 while 循环,比较 value 和 oldValue 此时均为 0,此时还有一个寄存器三,为 oldValue + 1(即此时为 1) 

会将 oldValue + 1 寄存器中的值 1 和 内存中的 0 进行交换 

这样就通过线程 2,将 value 从 0 -> 1,将 value 重新赋给 oldValue 最终 return oldValue 。

然后 t1 线程又被调度上来了,再执行 t1 线程 

注意:这个时候,t1线程再执行,value已经由0变为1了,但此时寄存器1的oldValue记录仍然是0,这里就会发现value和oldValue不同,意味着在CAS之前,另一个线程修改了value(这样就能识别出是否有其他线程修改)所以就不会进行交换,进入while循环,将calue的值重新赋给寄存器1的oldValue。

然后再进入 while 循环,这时候 value 和 oldValue 的值就相同了,然后还有另一个寄存器存储 oldValue + 1 。

再进行交换,将 value 从 1 -> 2,然后将 value 再赋值给 oldValue 返回 oldValue 。

之前的线程不安全,是因为内存中的值变了,但是寄存器中的值没有跟着变,接下来的++操作就会出错了,但是CAS这种方式,通过内存和寄存器的值进行比较,就能确保识别出的内存的值是不是改变了。没有改变,才进行++,如果改变了,就要重新读取内存中的值,确保是基于内存中最新的值进行修改。非常巧妙地就把线程安全问题解决了。

实现自旋锁

基于CAS实现更灵活的锁,获得更多的控制权。

自旋锁伪代码:当 owner 不为 null 的时候,意味着锁已经被其他线程持有。此时,当前尝试获取锁的线程并不会进入阻塞状态(不会像传统锁机制下调用 wait 方法一样阻塞)而是在这个 while 循环中不停的执行(“忙等”)。持续的尝试 CAS 操作区获取锁,只要获取不成功就一直循环,不放弃 CPU 资源,但也不参与 CPU 调度中的线程上下文切换等调度流程,避免了调度开销。

但是这种方式自旋的锁会一直占用CPU,消耗更多的CPU资源。

CAS的ABA问题

这个问题就像”翻新机”,我们以为买到的是一个新的机器,实际上买到的是一个“二手的机器”,外表看起来是崭新的,但是内部已经是别人的形状了。

CAS在使用的时候会判定当前内存中的值是否和寄存器中的值是一样的,如果是一样的就进行修改,不一样,就什么也不做。 

但是如果代码在执行过程中,有其他线程穿插进来,就可能出现这样的情况,比如数值本来是0,执行CAS之前,一个线程把这个值从0->100,另一个线程又从100->0,虽然最终结果仍然是0,但并不是没有别的线程穿插,而是其他线程将值又修改回去了。一般来说,即使出现上述情况,问题也不大,不会产生太大的bug,但是还是可能出现的情况:

假设我们现在要去银行取钱:

初始情况下,我们的账户余额为1000,要取500。取钱的时候,ATM机卡了,于是我们就按了两下(此时就产生了t1、t2线程去进行扣款操作了)。

如果是按照上述两个线程来执行,是可以正常运行的,不会出现bug的!!!但是,如果我在此时给账户存了500块,就有很大可能会出现bug了。

t1线程执行到这里,就不知道,当前的balance中的1000是个什么情况了,是始终没有变化呢还是变了又变回来了???如果认为没有变化,那可能就会再扣我们500块,此时我们就不买账了!!!

一个线程将value从A->B,另一个线程从B->A,重新触发CAS的修改机制,这就是A->B->A问题。 

那么对于这种问题,我们程序员又有什么应对之策呢?

1、约定数据变化,只能是单向的(只能增加/只能减少),不能是双向的(又能增加,又能减少)。

2、对于本身就必须双向变化的数据,可以给它引入一个版本号。版本号这个数字是只能增加,不能减少的。


JUC(java.util.concurrent)中的常见类

JUC这个包里存放着进行多线程编程的时候有用的类

Callable接口

回忆一下,我们之前创建线程的方法:

1、继承Thread类(包含匿名内部类实现子类的形式)。

2、实现Runnable接口(包含匿名内部类实现子类的形式)。

3、lambda表达式实现子类。

4、基于线程池实现线程。

Runnable关注的是过程,不关注执行结果 ,Runnable提供的run方法,返回值类型是void,Callable要关注执行的结果,Callable提供的call方法,返回值是线程执行任务获得的结果。

假如我们要编写一个从计算从1~1000的计算器,此时我们就可以这么写:

1、使用run方法的写法

package Thread;
//使用callable更好地解决这个问题
public class ThreadDemo35 {
    private static int sum = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            int result = 0;
            for (int i = 1; i <= 1000 ; i++) {
                result += i;
            }
            sum += result;
        });
        t.start();
        t.join();
        System.out.println("sum = "+sum);
    }
}

上面的代码虽然能够解决问题,但是,解决的方式不太“优雅” 。

2、 使用call方法的写法

package Thread;

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

public class ThreadDemo36 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 1; i <= 1000; i++) {
                    result += i;
                }
                //不需要引入成员变量
                //直接借助这里地返回值即可
                return result;
            }
        };
        //引入FutureTask来作为thread和callable的粘合剂
        FutureTask<Integer> futureTask = new FutureTask<>(callable);//未来的任务
        Thread t = new Thread(futureTask);//Thread没有构造方法来传入callable
        t.start();

        //也是带有阻塞功能的
        System.out.println(futureTask.get());
    }
}

注意: 

1、Collable后面所跟的泛型是我们期待线程的入口方法中,返回值的类型。此时就不需要引入成员变量了,直接借助这里的返回值即可。

2、Thread类并没有提供构造方法来传入callable,我们可以引入FutureTask类,来作为Thread和callable的“粘合剂”。

futureTask -> 未来的任务,既然这个任务是在未来执行完毕,我们最终去取结果的时候,就需要有一个凭据,这个凭据就是futureTask(举个例子:我们去配完眼镜之后,前台小姐姐会给我们一个单子,然后凭着这个单子来取眼镜,这个单子就是futureTask),此时这个代码就也不需要t.jion了。

 3、futureTask.get()这个操作也是具有阻塞功能的,如果线程还没执行完毕,get就会阻塞,等到线程执行完毕之后,return的结果,就会被get给返回回来~


ReentrantLock

ReentrantLock:是一种可重入锁(“Reentrant”意思是可重入的),与synchrnized定位类似,都是用来实现互斥效果,保证线程安全。

synchronized也是可重入锁。上古时期的Java种,sychronized不够强壮,功能也不够强大,也并没有我们上面所述的各种优化,ReentrantLock就是用来实现可重入锁的选择,后来synchronized被各种优化得变得厉害了之后,ReentrantLock用得就少了,但仍旧有一席之地。

ReentrantLock是传统锁的风格,这个对象提供了两个方法:lock(加锁)和unlock(解锁)。

但是这种写法,我们就容易lock加锁之后,忘记unlock解锁了、在unlock之前就return或者出现异常导致没有解锁成功。所以,正确使用ReentrantLock就需要把unlock操作放到finnally里面。

ReentrantLock和synchronized的区别

既然已经有了synchronized那为什么还要有ReentranLock呢?

1、ReentrantLock提供了tryLock操作。lock是直接进行加锁,如果加锁不超过,就会阻塞。但是trylock是尝试进行加锁,加锁不成功,不会阻塞,返回false——提供了更多可操作空间。

2、ReentrantLock提供了公平锁的实现(提供队列记录加锁线程的先后顺序)。在ReentrantLock的构造方法种填写参数,就可以将它设为公平锁。

3、搭配的等待通知机制不相同。对于sychronized,搭配的是wait/notify。对于ReentrantLock,则是搭配Condition类,功能比wait/notify略强一点点,可以精确地唤醒某个指定的线程。


信号量Semaphore 

信号量,用来表示“可用资源个数”,本质上是一个计数器。

举个例子:

可以把信号量想象城市停车场的展示牌:当前车位有100个,表示当前有100个可用资源。当有车开进去时,就相当于申请了一个可用资源,可用车位-1(这个称为信号量的“P”操作),当有车开出来的时候,就相当于释放一个可用资源,可用车位+1(这个称为信号量的“V”操作)。如果当前计数器的值已经为0了,再尝试申请资源就会阻塞等待,直到其他线程释放资源。

代码示例:

package Thread;

import java.util.concurrent.Semaphore;

public class ThreadDemo37 {
    public static void main(String[] args) throws InterruptedException {
        //将输入的信号的许可数量设为2
        Semaphore semaphore = new Semaphore(2);
        //尝试获取3次许可
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        //到这里会阻塞
        semaphore.acquire();
        System.out.println("P操作"); 
    }
}

运行结果:

可以看到在完成两次P操作之后,semaphore发生了阻塞,这是因为我们当前semaphore只有两个空间。此时,我们在申请第三个资源之前,释放掉一个资源再观察结果。

代码如下:

package Thread;

import java.util.concurrent.Semaphore;

public class ThreadDemo37 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(2);
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        //阻塞
        semaphore.release();
        semaphore.acquire();
        System.out.println("P操作");

    }
}

运行结果:

可以看到当前semaphore成功完成了3次P操作。

另外,Semphore的PV操作中的加减计数器操作都是原子的,可在多线程环境下直接使用。

信号量也是操作系统内部给我们提供的一个机制,JVM将操作系统对应的API进行了封装,就可以通过Java代码来完成这里的相关操作了。

信号量是更广义的锁!!!

所谓锁,本质上也是一种特殊信号量。锁,可以认为是计数值为1的信号量。释放状态是计数值为1的信号量;加锁状态,就是计数值为0的信号量。对于这种非0即1的信号量我们称为“二元信号量”。

使用信号量进行加锁:

package Thread;

import java.util.concurrent.Semaphore;

public class ThreadDemo38 {
    private static int count = 0;
    private static Semaphore semaphore = new Semaphore(1);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = "+ count);
    }
}

CountDownLatch

CountDownLatch是针对特定场景来解决问题的小工具。

比如,多线程执行任务,把大的任务,拆分成几个部分,由每个线程分别执行。

举个例子:“多线程下载”,譬如:IDM这样的软件。下载一个文件,这个文件可能很大,但是可以拆成多个部分,每个线程负责下载一部分,下载完成之后,最终把下载的结果都拼接到一起,这个拼接必须等到所有线程都执行完毕。使用CountDownLanch就可以很方便感知到上面这个事情(所有的线程执行完毕,比我们调用多次jion方法更简单方便一些) 

如果使用jion方法就只能每个线程执行一个任务,借助CountDownLatch就可以让一个线程执行多个任务。

package Thread;

import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.CountDownLatch;

public class ThreadDemo39 {
    public static void main(String[] args) throws InterruptedException {
        //此处构造方法中写10,意思是有10个线程/10个任务
        CountDownLatch latch = new CountDownLatch(10);
        
        //创建出10个线程进行下载
        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread t = new Thread(()->{
                Random random = new Random();
                int time = ((random.nextInt(5)+1)*1000);
                System.out.println("开始下载,线程:"+id);
                try {
                    Thread.sleep(time);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("结束下载,线程:"+id);
                //告知latch我执行结束了
                latch.countDown();
            });
            t.start();
        }
        //通过这个来等待所有任务完成,也就是countDown被调用10次了
        latch.await();
        System.out.println("所有任务都执行结束了");
    }
}

上述程序创建了10个线程并模拟进行下载任务,每个任务执行时间随机(1~5秒)。CountDownLatch的作用是让主线程等待所有10个下载线程完成后再继续执行。

1、主线程创建并启动10个下载线程。

2、每个下载线程随机休眠后完成任务,并调用countDown(),告诉CountDownLatch当前任务执行完成了。

3、主线程在await()处阻塞。

4、当10个线程都调用了countDown()后,计数器归0。

5、主线程恢复执行,输出“所有任务完成了”。

运行结果如下:

为什么要使用CountDownLatch?

1、替代jion():比逐个线程调用jion()更灵活,可以等待任意数量的线程。

2、一次性同步点:适合“主线程等待所有工作线程完成”的场景。

3、解耦等待逻辑:工作线程不需要知道其他线程存在,只需要调用countDown()。

线程安全的集合类

原来的集合类,大部分都不是线程安全的。但Vector,Stack,Hashtable是线程安全的,这三个类,在关键方法上加上了synchronized,因此,这几个数据结构,无论如何都得加锁,哪怕单线程的时候,也需要加锁,这样的做法是不科学的,这几个数据结构,现在官方已经不建议使用了,可能在未来的某个版本就删除掉了……

多线程环境使用ArrayList

1、程序员自己按照情况使用同步机制(synchronized 或者 ReentrantLock)

2、Collections.synchronizedList(new ArrayList)

这个包里面的方法,相当于是给ArrayList套了一个壳,ArrayList本身的各种操作都是不带锁的,但是通过上面的套壳操作之后,得到了新的对象,新的对象里面的方法就是带有锁的,这样更方便我们灵活使用。

3、使用CopyOnWriteArrayList

写时拷贝

ArrayList的线程安全问题,本质上是多个线程修改同一个数据时可能会出现问题。

例如有一个顺序表如下:

如果有多个线程,读这个顺序表,是没有任何线程安全问题的。但一旦有线程要修改里面的值,就可能引发线程安全问题。

如果使用CopyOnWriteArrayList,它如果发现有线程修改里面的值,他就会把顺序表复制一份,修改新的顺序表的内容,并且修改引用的指向(这个操作是原子的,无需加锁)。 

总结:

当我们往一个容器添加元素的时候,不直接往容器里面添加,而是线将当前容器进行 Copy,复制出一个新的容器,然后在新的容器里面添加元素。添加完元素之和,再将原容器的引用指向新的容器。

这样做的好处是,可用对 CopyOnWrite 容器进行并发的读,而且不需要加锁,因为当前容器不会添加任何的元素。所以 CopyOnwrite 容器其实也是一种读和写分离的思想,读和写是不同的容器。

优点: 在读操作多,写操作少的场景下,性能不是很高,不需要加锁竞争。

缺点:占用内存较多,并且新写的数据不能被第一时间读到。

多线程下使用Queue

1、自己加锁

2、使用BlockQueue

这里的阻塞队列之前介绍过,这里就不做过多赘述。

多线程下使用哈希表

HashMap

本身线程是不安全的,在多线程环境下,我们可以使用Hashtable(在关键方法上加了锁),更推荐使用ConcurrentHashMap。

Hashtable

1、只是简单地对关键方法上了锁

 相当于直接对Hashtable对象本身加锁

此时,尝试修改两个不同链表的元素,都会触发锁冲突(针对不同链表上的元素进行修改,不会引发线程安全问题,也就没必要加锁,只有针对同一链表进行修改是线程不安全的,此时才需要加锁)。 

2、Hashtable中维护元素数量的size属性,在涉及更新(如:新增元素/删除元素)和读取操作,也通过synchronized加锁

每次调用size方法和读写操作都需要先获取锁,在高并发场景下,频繁所获取和锁释放就会导致大量线程等待,极大地降低了操作效率。

   3. 一旦触发扩容,就由该线程完成整个扩容过程,这个过程就会涉及到大量的元素拷贝,效率非常低 —— 不稳定。

ConcurrentHashMap

相比于Hashtable进行了一系列的改进和优化。(在Java1.7之前,ConcurrentHashMap)是通过“分段锁”来实现的。给若干个链表分配一把锁,这样设定,不太合适,实现也复杂)

Java1.8后:

1、读操作没有加锁了(使用了volitile保证每次都从内存中读取结果),只对写到做进行加锁,加锁的方式任然是sychronized,但不是整个HashMap进行加锁,而是使用“锁桶”(用每个链表的头节点作为锁对象),这就相当于缩小了锁的粒度。

此时,操作不同的链表的时候就不会产生锁冲突了。而且上述设定,不会产生更多的空间代价。因为Java中任何一个对象都可以直接作为锁对象。本身哈希表中,就得有数组,数组的元素都是已经存在的,此时只需要使用数组元素(链表头节点)作为加锁对象即可。

2、充分利用CAS特性:比如size属性通过CAS进行更新,避免出现重量级锁的情况。

3、针对扩容操作进行优化——化整为零

扩容操作是哈希表中一个重要的操作,这里有一个概念是负载因子,即描述了每个桶上平均有多少个元素,当桶上的链表的元素个数不是太多,就能达到 O(1) 时间复杂度。

注意:负载因子不是 0.75!!!0.75 是负载因子默认的扩容阈值,不是负载因子本体负载因子是我们算出来的数,用实际的元素个数 / 数组的长度,那我们算出来的值和扩容阈值进行比较,来看是否需要扩容。

如果桶上的元素个数太多就会有两种机制:1、树化     2、扩容

扩容就是创建一个更大的数组,把旧的hash表的元素给搬运到新的数组上,如果hash表此时的元素非常多,这里的扩容操作就会消耗很长的时间!!!(hash表平时存储数据都表现得很快,突然某次存储非常慢(扩容花费时间),然后过一会就又快了,这样的表现是不稳定的,无法控制什么时候扩容)。

所以ConcurrentHashMap就优化为了化整为零,蚂蚁搬家

1、 发现需要扩容的线程,会创建一个新的数组,只搬运几个元素过去。

2、 扩容期间,新老数组同时存在。

3、 后续每个来操作 ConcurrentHashMap 的线程,都会参与搬运的过程,每个操作负责搬运一小部分元素。

4、 搬完最后一个元素,再把老的数组删掉。

5、 这个期间,插入只往新数组中添加。

6、 这个期间,查找需要同时查新数组和老数组。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值