多线程进阶
锁的策略
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>() {@Overridepublic 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没有构造方法来传入callablet.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 {//将输入的信号的许可数量设为2Semaphore 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、 这个期间,查找需要同时查新数组和老数组。