多线程(锁策略)

回顾:实现一个定时器:

1)我们首先先使用一个Task类来进行描述一个任务,使用Runnable+time来进行描述;

2)我们使用带有优先级的阻塞队列;

3)我们需要进行扫描线程,不断地从队首中获取元素,进行检测时间是否到达,并开始进行执行任务,通过合适的wait来防止扫描线程出现忙等;

4)实现Schedule方法,向定时器中添加任务

实现一个线程池:

1)使用Runnable来进行描述一个任务;

2)使用阻塞队列来进行描述若干个任务;

3)创建一个Worker线程类,来进行描述一个工作线程,完成的任务就是说从我们的阻塞队列中获取元素,并开始执行任务;

4)创建一个List来进行组织若干个线程对象;

5)实现我们的submit方法,把任务提交给队列中;

1)如果synchronized是修饰普通方法的,就针对当前this(当前对象的引用)进行加锁

如果两个线程同时调用这个方法的时候,不一定会触发锁竞争的操作,看是否出发锁竞争就看当前锁住的对象是不是同一个了,如果是不同的对象调用increase,就不会触发锁;

2)如果synchronized是修饰静态方法的,当前锁住的是当前类的类对象,由于类对象是单例的,所以两个线程并发调用该静态方法就一定会竞争锁;

Java中任意一个对象都可以作为锁对象,这里面包含对象头,对象头包含了一些各种对象的公共属性;

3)所谓加锁操作,就是把当前指定的锁对象的锁标记设成true,所谓的解锁操作,就是把指定的所对象的锁标记设成false;

4)如果两个线程针对同一个锁对象对象加锁,此时一个线程会先获取到锁,另一个线程就会阻塞等待;如果两个线程针对不同的锁对象加锁,此时两个线程都会获取到各自的锁,不会出现锁竞争,针对同一个对象加锁,才有锁竞争关系;

3)此外synchronized会解决内存可见性的问题,都会把数据真的从内存里面读,这就相当于让程序跑的慢一点,但是算得准,但是volatile一个线程读一个线程写的场景,只能保证内存可见性,不可以保证原子性;

4)synchronized会有可重入,就是允许一个线程针对一把锁,咔咔加锁两次;

sychronized public void increase()
{
    sychronized(this){//在这里阻塞了,无法释放第一把锁
        count++;
     }
}
进入increase方法后加了一次锁,进入代码块以后又加了一次锁,按理说会出现问题;
因为第一次加锁后,此时对象头的所标记已经是true,线程就要被阻塞等待,等待这个锁标记改成false,才可以继续竞争这把锁,他会内部记录当前的哪个线程持有这把锁

5)标准库中的集合类

5.1)对于Arraylist,Linkedlist,HashMap,Hashset都是线程不安全的,不可以在多线程情况下并发修改同一个对象;

5.2)vector是一个动态数组,里面使用了synchronized,几乎给每个方法都加了锁,这么干并不好,大多数是在单线程下使用,会对单线程环境下的程序的操作效率进行影响;

5.3)还有一个HashTable也是把很多方法加了synchronized,不建议使用;

5.4)Stack,Concurrenthashmap,stringbuffer都很不好,不建议去使用;

5.4)string也是线程安全的,虽然没加锁,是不可变对象,不可能存在两个线程同时修改string的值,要想修改必须new,必须要创建新的String对象

5.5)但是Synchronized虽然保证了线程安全,但是在性能上并不是最优的,Synchronized会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高;

我们在来进行理解一下:

1)我们的synchronized修饰同步代码块的时候,里面要指定锁对象,也就是说我们要针对哪一个对象来进行加锁,当我们的多个线程尝试针对同一个对象来进行加锁的时候才会出现锁竞争,才会出现阻塞等待,但是我们的多个线程针对不同的对象来进行加锁,此时就不会涉及到阻塞等待;

2)我们的所谓的加锁操作,其实本质上来说就是在修改一个对象头里面的数据,每一个对象都有自己的头部数据,那么我们到底要修改谁的数据呢?

多线程(锁策略)_第1张图片

1)也就是说在我们的实际开发中,也是同理,我们的一个程序里面可能会有很多很多的线程,这么多线程,有些线程之间是需要进行互斥的,有些线程之间是丝毫不会进行互斥的;

2)在如此多的线程之间,哪一些线程在针对我们的同一个对象进行加锁,那么就会有互斥关系,对应的线程没有针对同一个对象进行加锁,就不会有互斥关系;

3)比如说locker.notify()唤醒的是针对同一个locker对象加锁wait的线程;

常见的锁策略:

加锁是一个开销比较大的事情,我们希望在特定场景下,针对一些场景进行取舍,好让锁更高效一些,策略,就是解决问题的具体思路,是一个更加通用更加高效的级别;

你是怎么理解乐观锁和悲观锁的?具体是怎么实现的呢?

1)乐观锁:认为每次锁冲突的概率比较低,甚至于基本没有冲突,所以在每次的数据提交更新的时候,才会对数据提交产生并发冲突而去检测,不需要做什么太多的准备;

2)悲观锁:总是假设最坏的情况,锁冲突的概率比较高,每次一开始的时候拿数据的时候都会认为别人会修改,所以在每次拿数据的时候都会上锁,做的事情更多,此时就会愿意付出更多的成本来处理冲突;

3)悲观锁的实现就是先加锁,获取到锁就操作数据,获取不到锁就阻塞等待;

乐观锁:在一开始拿到数据的时候并不会认为我们计算数据的过程中有别的线程发生冲突,所以我们在最后进行更新数据的时候,才会进行检测;

悲观锁:拿数据的时候,就认为刚拿数据的时候我未来会修改的时候,别的线程也会认为别人会修改这个数据,所以在拿数据的时候就直接进行加锁了;

锁竞争:多个线程同时竞争同一把锁,就是锁冲突

悲观锁:预期锁冲突的概率比较高,就会认为我一进行加锁,就会产生冲突,就是锁竞争的概率高,激不激烈

乐观锁:预期所冲突的概率比较低,锁冲突不激烈

1)举一个例子:比如说发生洪水
乐观锁:有的人就会比较乐观,觉得这个事情出现的概率比较低,就不会提前做准备,等到洪水来了现准备
悲观锁:比较悲观,总是认为这件事会发生到自己身上,提前就做出准备
乐观锁:假设一般情况下都不会产生冲突,甚至没有冲突,因此就会直接访问数据,如果发现了锁冲突,再去处理
悲观锁:假设一般情况下都会产生这种冲突,因此会先进行处理,然后再去尝试访问数据
2)例如A,B两个同学想要想问老师问题

A:认为老师比较忙,问问题老师不一定有时间,因此A就会给老师发一个问题,在吗?有空吗?(悲观锁)
B:认为老师是比较空闲的,问问题老师是大概率会有时间的,因此B就会直接上来问,如果我这边确实有空,就回答了,没空就等一会(乐观锁)

3)疫情买菜

乐观态度:认为下一波疫情即使来了,认为菜也会能够买到,根据前两波疫情的经验,就不必做什么专门的特殊的准备,乐观锁整体做的事情少,就会变得会更轻量;

悲观态度:认为下一波疫情来了,就啥也买不到了,就赶紧存物资,比如说米,面,换一个更大的冰箱,定期买粮食,方便面,矿泉水,各种常用药,悲观锁在这里面要做的事情更多要付出的成本和代价也是越来越高的,不光花钱,占地方,还费时间

2)前面介绍的Synchronized其实是以悲观锁为主,对象头随时准备设成false,其实Synchronized初始使用乐观锁,发生冲突后才改成false,但是当发现锁竞争比较激烈的时候,就会自动切换到悲观锁策略;

1)觉得这个女生有很多人追,那么就是悲观锁

2)觉得这个女生没有人追,自己好追,那么就是乐观锁

记这个:

悲观锁,做的工作越多,付出的成本更多,更低效

乐观锁,做的工作更少,付出的成本更低,更低效

使用乐观锁:我们就要引入版本号这个策略,就可以使用这个方案来代替阻塞等待的锁;

1)假设当前余额剩了100,我们也引入一个版本号,verson初始值是1,并且我们规定,提交版本必须大于当前版本记录才能执行更新余额,如果提交的版本小于当前版本,就说明其他线程已经把这个数据给修改了,直接贸然直接提交就会引起线程不安全,这样的情况我们就应该就放弃修改;

2)我们把内存的数据读到寄存器里面之后,我们要先进行修改数据操作,再将版本号加1,然后进行对比两个版本号,内存中的版本号和CPU中的版本号进行比较,然后再进行判断是否要把CPU中修改的数据写回到内存里面;

多线程(锁策略)_第2张图片

多线程(锁策略)_第3张图片

3)一开始,线程一和线程二,以及读入的内存中的版本号都是1,当线程一想要修改数据的时候,先将版本号进行加1,就变成了2,此时内存中的版本号是1,发现当前线程1提交的版本号大于内存中的读取的版本号(2>1),就可以进行修改;

4)线程二也想进行修改数据,但是当前线程的版本号是1,内存中的版本号是2,线程二提交数据之前会将版本号加一,变成二,但是当前内存中的版本号是2,提交版本必须大于当前版本才可以执行更新的乐观锁策略(说明之前线程已经把数据进行了修改),所以就认为这次操作失败,不可以进行修改数据;

基于版本号这样的实现,就是基于乐观锁的一种典型实现,这是光改版本号,就是在用户态里面完成即可

1)对于这个机制来说,如果写入失败就需要重试,要是一直重试的话,效率其实就不高,但是我们是把锁放在冲突概率很低的程序中使用的,基本就很少涉及到重试的,之前单纯的互斥的锁(会涉及到线程的阻塞和唤醒),是会涉及到用户态和内核态的切换的;

2)但是这个乐观锁很少涉及到重试,像一些用户量不多的,小门小户的网站,就可以优先使用乐观锁;

3)锁冲突概率比较低,就很少可能会发生版本不符合要求,修改数据失败的情况;

介绍一下读写锁(处理锁冲突的态度)VS普通互斥锁

1)有些场景中,本来就是写的情况比较少,读的情况比较多
两个读线程之间,其实不会涉及到线程安全问题,就不必进行互斥
两个写线程之间,其实存在线程安全问题,就存在互斥
2)一个读线程和写线程之间,其实也存在线程安全问题,因此也需要互斥
因此,在实际情况下,就可以根据读和写的不同场景,给读和写分别进行加锁

3)对于普通的互斥锁来说,是只有两个操作的,加锁和解锁,只要两个线程针对同一个对象进行加锁,就会出现互斥现象;

4)但是我们的读写锁,分成了三个操作

4.1)加读锁:如果我们的代码只是针对读操作,就加读锁;

4.2)加写锁:如果代码当中只是进行修改操作,那么就加写锁;

4.3)解锁;

1)正常情况下,多线程之间的同时读取同一个变量不会涉及到线程安全问题,但数据的写入方之间以及读者之间都用到同一个锁,就会产生极大的性能消耗;

2)就是进行读和写操作分别进行加锁,对他们的操作分开了,对于我们的读写锁来说,读锁和读锁之间不会进行互斥因为多个线程同时读取同一个变量就不会出现线程安全问题,读写锁只存在两种效果写锁和读锁之间存在互斥,写锁与写锁之间存在互斥,多线程同时读取同一个变量,很多场景,都是读多写少

3)读写锁最适用于读的情况下比较多,写的情况下比较少,一旦进行冲突,避免进行阻塞等待,一旦进行阻塞等待,比较影响程序的效率,很多场景都是读多写少,读的场景就需要进行互斥的,这样程序运行的速度就会更多;

4)这种读写锁比synchronized的效率更好,因为咱们的synchronized对于咱们的无脑并发读操作也是会进行加锁的;

Synchronized不是读写锁,注意,只要涉及到互斥,就会产生线程的阻塞等待,再次唤醒就不知道隔了多久了,因此尽可能减少互斥的机会,这是提高效率的重要途径;

在java的标准库中,提供了一个类来创建读锁实例和写锁实例

ReentrantReadWriteLock.ReadLock 能够创建一个读锁实例                                                ReentrantReadWriteLock.WriteLock 可以创建一个写锁实例

假设现在有10个线程,t0和t9是写线程,t1到t8是读线程,

1)如果此时 t1和t2两个读线程同时访问数据,两个读加锁并不会互斥,完全并发的执行,就好像从来没有加过锁一样;                                                                                                        2)但是如果是t0和t1线程同时进行访问,此时读锁和写锁之间就会进行互斥,要么是读完在写,要么是写完再读;

3)如果要是t0和t9两个写线程同时访问,那么此时就会出现锁竞争的操作,必须等到一个线程写完了,下一个线程在写;

3 重量级锁和轻量级锁(处理锁结构之后)

1)咱们可以认为乐观锁,悲观锁这是原因;

2)重量级锁轻量级锁这是一个结果;

这些锁策略量级锁:加锁解锁开销并不是全部互不相关的,可能也会有部分的重叠

1)重量级锁:加锁解锁开销很大,通常是在操作系统的内核中进行的,悲观锁做的工作往往更多,开销也很大,因此悲观锁很有可能是重量级锁,就是做了更多的事情,开销更大,操作系统中的锁会在内存中做很多的事情,比如说让线程阻塞等待;

2)轻量级锁:加锁解锁开销更少,通常是由用户态来做的,乐观锁做的工作要少一些,开销要少一些,所以乐观锁很有可能是轻量级锁,用户态的代码更可控,也会更高效;

3)乐观锁和悲观锁描述的是应用场景,看锁的冲突概率高不高;但是重量级锁和轻量级锁描述的是,加锁解锁的开销力度大不大;

4)也就是说咱们的乐观锁悲观锁是处理所冲突的态度,也是原因,咱们的重量级锁轻量级锁是处理锁冲突的结果,是我们已经把代码实现好了,你此时发现,这种实现方式有一点重,或者是实现方式比较轻;

1)咱们一般认为在我们所使用的锁当中,如果说锁是基于内核中的一些功能来进行实现的,比如说调用了操作系统提供的mutex接口,此时一般认为这是重量级锁,因为我们此时操作系统的锁会在内存中做很多事情,比如说让线程阻塞等待;

2)如果说我们的锁是在用户态实现的,那么一般认为这是一个轻量级锁,因为用户态的代码更可控,也更高效;

加锁这里的互斥能力是怎么来的呢?     

1)归根结底,是CPU的能力,CPU提供了一些特殊的指令,通过这些指令来完成互斥,操作系统内核对这些指令完成了封装,并实现了阻塞等待,CPU提供了一些特殊指令(原子),操作系统对这些指令封装了一层,提供了一个互斥量(mutex)(API接口),像linux就会提供一个mutex接口(这个能力来自于硬件,CPU,操作系统),让用户代码进行加锁解锁;

2)Java中的JVM就会对操作系统提供的mutex再进行封装一层,就形成了synchronized这样的锁,如果当前的锁,是通过内核中的mutex来完成的,这样锁的开销就比较大;

3)但是如果是通过用户态,通过一些其他的手段来完成的,这样的锁的开销就比较小

4)synchronized即是轻量级锁,又是重量级锁,根据场景,来进行自适应;

介绍自旋锁和挂起等待锁 

1)咱们的挂起等待锁,就是通过操作系统内核的一些机制来进行实现的,往往比较重,这是重量级锁的典型实现;

2)而咱们的自旋锁往往就是通过用户态的代码来进行实现的,往往比较轻,这是轻量级锁的一种典型实现;

1)挂起等待锁:如果当前线程获取不到锁,就会阻塞等待,啥时候结束阻塞,完全取决于操作系统具体的调度,当线程挂起的时候,是不会占用CPU资源的(等待自己被唤醒),往往是通过内核的一些机制来进行实现的,往往比较重,只是阻塞等待,也不会进行尝试,就等待内核的安排,自己啥活也不干,是不会占用CPU资源的,当我们的线程1和线程2在竞争同一把锁的时候,线程1拿到锁,线程2没有拿到锁,线程2就会阻塞等待,当线程1把锁释放的时候,线程2不会立即的获取到锁,而是要等操作系统进行一系列调度之后(内核安排),线程2才能获取到锁;

2)自旋锁:当线程获取不到锁,不会阻塞等待,而是循环的在快速尝试一次,重复尝试,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁(反复尝试),往往是通过用户态代码来进行实现的(这样的重复尝试是CPU在默默支持的,会占据CPU的资源)--------主动获取到锁,一旦线程1拿到了锁,线程2就会快速的循环来进行尝试获取到锁,一旦线程1把锁释放,线程2就能第一时间获取到锁,但是我们的自旋锁重复尝试获取到锁就会一直吃CPU;                                                              

自旋锁:如果线程一拿到了锁,线程二就会快速循环的来尝试获取到锁,一旦线程1把锁释放,线程二就会第一时间获取到锁,问题就是,太浪费CPU资源;

自旋锁的伪代码:
1)while(抢锁失败)
{
}

自旋锁:按照之前的方式,线程在抢锁失败后会进入阻塞等待,会放弃CPU需要等到好久才会被调度自旋锁,如果获取到锁失败就立即尝试再获取到锁,无限循环,直到获取到锁为止,第一次获取到锁失败,第二次的尝试会在极短的时间内到来;

自旋锁是一种轻量级锁的实现方式:

1)优点:一旦有被释放,就可以第一时间获取到锁;没有放弃CPU,不涉及线程等待和线程调度;

2)缺点:如果所被其他线程占用的时间太长,就会持续地消耗CPU资源,但是挂起等待的时候不会消耗CPU资源;

synchronized中的轻量级锁大概率就是通过自旋锁的方式来实现的,那么什么时候使用挂起等待锁,什么时候使用自旋锁呢?

1)如果锁冲突的概率比较低,建议使用自旋锁,锁冲突概率比较高,那么自旋锁一直空转的时间就比较长了,他此时正在忙,忙等情况下是很吃CPU资源的,所以如果你线程冲突概率高的话,那就意味着其他线程就会在这里都尝试获取锁,那对计算机消耗是很大的,负担是很重的;             

2)如果线程持有锁的时间比较短,建议使用自旋锁;                                                                3)如果对CPU比较敏感,不希望吃太多的CPU资源,建议使用挂起等待锁,synrchronized中的自旋锁和挂起等待锁,都内置了,会自动适应;

4 公平锁和非公平锁   

1)针对公平锁来说:多个线程在进行等待同一把锁的时候,遵从先来先到的规则,谁排队在前面谁就先获取到锁,排在后面就给我干等,谁是先来的,谁就能先获取到锁;

2)不公平锁:多个线程在获取同一把锁的时候,不遵循先来后到的规则,排在前面和排在后面的线程抢到锁的概率是一样的,就完全取决于操作系统的调度了,每一个等待的线程获取到锁的概率都是均等的;

3)我们要想实现公平锁,就要有额外的数据结构(例如有个队列,通过这个队列记录先来后到的线程,大部分情况下,实现非公平锁就够了,但是如果涉及到期望线程的调度的时间成本是可控的

4)我们要进行注意:我们此处约定的是,先来后到才是公平;

1)对于我们的操作系统来说,本身线程之间的调度就是随机的,他们是机会均等的,我们的操作系统提供的这一个mutex这个锁,就是一个非公平锁 

2)咱们的上述的调度的这个过程,是我们进行考虑相同的优先级的情况,实际开发中并不会手动修改线程的优先级,因为我们进行修改之后在我们宏观上面的体会不会特别明显

3)如果说我们想要实现公平锁,我们就需要付出更多的代价,就是我们可能说本身还要进行实现一个队列,把这些先来后到的线程给进行拍一拍先来后到

1)这个时候就需要公平锁了

2)例如:例如发射卫星,通过十个线程并发执行十个任务,如果使用非公平锁,就可能出现极端的情况,9个线程霸占锁,第十个线程没获取到锁过,这是系统会崩溃,所以使用公平锁,来保证这10个任务来均匀的均衡来执行,不均衡,就是某些线程一直持有锁,某些线程根本拿不到锁;

3)正是有这样的需求,才产生了实时操作系统的,线程调度花的时间是可控的,我们平常使用的都不是实时操作系统,synchronized是非公平锁

5.可重入锁和不可重入锁

1)如果针对同一把锁,卡卡加锁两次 ,如果是不可重入锁,就会陷入死锁问题 

2)如果是可重入锁就不会出现死锁问题 

 static synchronized void run1()
    {
       run2();
    }
    static synchronized void run2()
    {
        System.out.println(1);
    }
这个代码会存在很多问题,这两个方法都在针对同一个对象(this)在加锁,run1获得锁加锁成功,接下来执行fun2,尝试获取到锁,但是run2获取到锁的前提是run1先释放锁呀!但是run2方法在run1方法内部一直阻塞等待,run1方法也无法获取到锁,线程此时就很尴尬

引入可重入概念的时候,就要解决死锁问题,让当前的锁记录一下这个锁是当前哪个线程持有的,如果当前发现当前有同一个线程尝试获取锁,这个时候就让代码能够继续运行而不是出现阻塞等待,同时也在这个锁里面维护一个计数器 ,这个计数器就要记录当前这个线程针对这把锁加了几次锁,每次加锁就让计数器++,每次解锁就让计数器减减,直到计数器是0了,此时才真的释放锁,才可以真的让其他线程获取到锁,synchronized就是一个可重入锁;   

ThreadLocal介绍: 

ThreadLocal是用来提供线程内部的局部变量,这种变量在多线程环境下访问(通过get和set来进行访问,这样就可以保证各个线程的变量可以独立于其他线程的变量,这种ThreadLocal的实例一般都是private static类型的,用于关联线程和上下文

所以说ThreadLocal里面提供线程内的局部变量,不同的线程之间不会相互干扰,这样的变量在线程的生命周期中起作用,减少同一个线程内多个函数或者组件之间一些公共变量传递的复杂度         

   public class HelloWorld {
        private String content;
        ThreadLocal local=new ThreadLocal<>();

        public String getContent() {
            String content=local.get();
            return content;
        }

        public void setContent(String content) {
            local.set(content);
            this.content = content;
        }

        public static void main(String[] args) {
          HelloWorld helloWorld=new HelloWorld();
            for(int i=0;i<5;i++)
            {
                Thread thread=new Thread(new Runnable() {
                    @Override
                    public void run() {
             helloWorld.setContent(Thread.currentThread().getName()+"数据");
             System.out.println("_____________________");
             System.out.println(Thread.currentThread().getName()+"---->"+helloWorld.getContent());
                    }
                });
                thread.setName("线程"+i);
                thread.start();
            }

        }
    }

       但是当我们使用synchronized操作的时候,也是可以实现上述效果的:

package com.example.demo;


    public class HelloWorld {
        private String content;
        ThreadLocal local=new ThreadLocal<>();
        public String getContent() {
            return content;
        }
        public void setContent(String content) {
            this.content = content;
        }
        public static void main(String[] args) {
          HelloWorld helloWorld=new HelloWorld();
            for(int i=0;i<5;i++)
            {
                Thread thread=new Thread(new Runnable() {
                    @Override
                   public void run() {
                        synchronized (Object.class) {
                            helloWorld.setContent(Thread.currentThread().getName() + "数据");
                            System.out.println("_____________________");
                            System.out.println(Thread.currentThread().getName() + "---->" + helloWorld.getContent());
                        }
                    }
                });
                thread.setName("线程"+i);
                thread.start();
            }

        }
    }

                                                             

我们所说的锁策略,是在描述这一把锁具有哪些特点,一把锁是具有很多特点的

synchronized的特性:

1)它既是一个乐观锁,也是一个悲观锁;

2)它不是一个读写锁,只是一个普通互斥锁;

3)它既是一个轻量级锁,也是一个重量级锁,它是根据锁竞争的激烈程度来进行自适应的;

4)轻量级锁的部分是基于自旋锁来进行实现的,重量级锁的部分是基于挂起等待锁来进行实现的;

5)synchronized是一个非公平锁,他还是一个可重入锁;

你可能感兴趣的:(面试)