javaEE-多线程(3)
目录
一,常见的锁策略
1,乐观锁/悲观锁
2,重量级锁/轻量级锁
3,挂起等待锁/自旋锁
4,公平锁/非公平锁
5,可重入锁/不可重入锁
6,读写锁
synchronized原理
偏向锁
锁消除
锁粗化
二,CAS
1)基于CAS实现"原子类"
2)ABA问题
三,JUC(java.util.concurrent)
1,Callable 接⼝
2,ReentrantLock
3,信号量Semaphore
4,CountDownLatch
四,线程安全的集合类
解决方案:
1)自己加锁
2)使用带锁的List
3)使用CopyOnWrite集合类
3)多线程下使用队列
4)编辑ConcurrentHashMap
一,常见的锁策略
1,乐观锁/悲观锁
乐观锁:加锁时候,假设出现锁冲突的概率不大,接下来围绕锁要做的工作就会很少
悲观锁:加锁时候,假设出现锁冲突的概率较大,接下来围绕锁要做的工作就会很多
2,重量级锁/轻量级锁
重量级锁:开销更大(时间开销),要做的工作更多,往往是悲观锁
轻量级锁:开销较小(时间开销),要做的工作较少,往往是乐观锁
3,挂起等待锁/自旋锁
挂起等待锁:属于悲观锁/重量级锁的典型代表,会让出CPU资源,过了一段时间之后,通过其他途径得知锁被释放了,然后再去获得锁(这种场景往往是锁竞争特别激烈,拿到锁的概率本身也不大,所以不妨将CPU让出来)
自旋锁:属于乐观锁/轻量级锁的典型代表,"忙等"等待的过程中不会释放CPU,会不停的检测锁是否被释放,一旦锁被释放就立刻有机会获得锁了
4,公平锁/非公平锁
公平锁/非公平锁:先来后到的方式定义为"公平",本身上操作系统针对锁的处理是以非公平的方式,如果需要实现公平锁,就需要额外的操作(比如:引入队列,记录每一个线程加锁的顺序)
5,可重入锁/不可重入锁
可重入锁/不可重入锁:对于一个线程,针对一把锁,连续加锁两次,就有可能出现"死锁",如果设置为可重入锁,就可以避免死锁了
1)记录当前是哪个线程持有这把锁
2)在加锁的的时候判定,申请的锁是否是之前申请锁的线程.
3)计数器,记录就锁的次数,从而确定何时真正释放锁
6,读写锁
读写锁:把"加锁操作"分成了两个情况提供了两种加锁的API,读加锁,写加锁,解锁的的API是一样的.如果两个线程都是按照读方式加锁,此时不会产生锁冲突(相当于对读操作进行了一个优化),如果两个线程都是加写锁,此时就会产生冲突,如果一个线程一个是读锁,一个是写锁,也会产生冲突
----------------------------------------------------------------------------------------
Java标准库提供了一个类ReentrantReadWriteLock实现的读写锁,可重入锁/读写锁.这个类里面又包含了两个内部类
ReentrantReadWriteLock.ReadLock 类表⽰⼀个读锁. 这个对象提供了 lock / unlock ⽅法 进⾏加锁解锁. • ReentrantReadWriteLock.WriteLock 类表⽰⼀个写锁. 这个对象也提供了 lock / unlock ⽅法进⾏加锁解锁.
synchronized原理
synchronized原理:只是锁中的一种典型实现,是自适应的,一开始乐观锁,但是当锁冲突到一定程度时,就会变成悲观锁.轻量级锁就是基于自旋的方式实现的(JVM内部,用户态代码实现的),重量级锁就是基于挂起等待的方式实现的(调用操作系统的API在内核中实现的).可重入锁,属于非公平锁.非读写锁
synchronized的加锁过程:锁升级,刚开始使用synchronized加锁,首先是"偏向锁"状态,遇到线程的时候会升级到"轻量级锁",进一步统计竞争出现的频次,到达一定程度后升级为"重量级锁",锁升级的过程是为了适应不同的场景,降低程序员的负担,锁升级对于JVM是一个不可逆的过程(不能降级).
偏向锁
偏向锁:一开始就只是做了一个标记,并不是真正加锁,如果线程来竞争锁,就会加上锁.本质上是在推迟加锁的时机(懒汉模式思想)
总结:偏向锁->轻量级锁:出现竞争.轻量级锁->重量级锁:竞争加剧
锁消除
锁消除(编译器优化策略):编译器会对synchronized的代码做出判断,判断这个地方那个是否需要加锁,如果不需要就会把这个锁给取消掉(这只是一个辅助,并不能无脑加锁!!)
锁粗化
锁粗化(编译器优化策略):锁的粒度,synchronized的代码块,执行的代码越多,粒度越粗.锁粗化,就是把多个"细粒度"的锁合成一个"粗粒度"的锁(每次加锁都涉及到阻塞等待)
二,CAS
(Compare and swap)比较内存和CPU寄存器中的内容,如果发现相同,就进行交换(交换的是内存和另一个寄存器的内存)
一个内存数据和两个寄存器进行操作:比较内存和寄存器1的内容是否相同,如果相同就将寄存器2的内容与内存中与寄存器1相同的内容进行交换(我们更关心内存里面的内容,因此与其说交换,不如说赋值给内存).CAS的关键不在于这个逻辑能干嘛,而在于这是通过"一个CPU指令"完成的(原子的),因此这会给程序员带来新的思路,实现"无锁化编程"
运用场景:
1)基于CAS实现"原子类"
int/long在进行的时候都不是原子的,但是可以通过CAS来实现"原子类",本质上就是对int/long++--进行封装.从而完成++--这种操作
原子类在Java标准库中也有
这就是JVM对CAS进行封装,native修饰的方法是"本地方法"这个方法实现是在JVM内部通过c++代码实现的
这里的不安全(这里的代码,偏底层逻辑,需要程序员对操作系统和硬件有一定的了解,才能够使用这里的代码逻辑,一般不建议直接使用unsafe)
如何通过CAS实现原子类:
oldValue代表寄存器1的值,value代表内存里面的值,oldValue+1代表寄存器2的值.首先将内存的值赋值到寄存器1中(value赋值到oldValue中),进行CAS操作,这时会先对比value和oldValue里面的值是否相同,如果相同则意味着赋值和while循环操作中没有插入其他线程.此时就可以直接修改内存的值.但是如果value和oldValue里面的值不相同,就意味着上方赋值和CAS中间穿插了其他线程,这时CAS就会返回true,从而进入循环,重新进行复制操作(value再次赋值到oldValue中).
2)ABA问题
CAS之所以安全,是因为在通过CAS比较的过程,来确认是否在执行过程中有别的线程插入了该线程,但是当两个线程同时穿插的改变一个变量的时候,其中线程2将这个变量由A变成了B,之后又变成了A.这时候线程1的CAS进行比较内存和寄存器中的值的时候,这个两个值还是相同的.就会继续原来的操作.虽然变量A的值没有改变,但是整个过程中还是产生了一些其他的操作.这些操作就有可能产生的问题,这种由于变量在CAS中反复横跳产生的问题就是ABA问题
⼤部分的情况下, t2 线程这样的⼀个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除⼀ 些特殊情况.比如银行转账,先进行一个转出500的操作,由于按了两次转账按键所以产生了两个线程,与此同时有一笔转入,金额恰好也是500,这是就会发生以下问题:
相当于进行了一次转出账操作,却进行了两次.
解决方案:设置版本号
三,JUC(java.util.concurrent)
1,Callable 接⼝
Callable->call方法=>带有返回值
Runnable->run方法=>返回void
Callable<Integer> callable=new Callable<Integer>() { @Override public Integer call() throws Exception { int sum=0; for (int i = 0; i < 100; i++) { sum+=i; } return sum; } }; FutureTask<Integer> futureTask=new FutureTask<>(callable); Thread thread=new Thread(futureTask); thread.start(); System.out.println(futureTask.get());
Thread不能直接Callable的引用,但是可以借助FutureTask来间接实现使用.futureTask.get()带有阻塞功能,当前Thread没有执行完的时候,get就会阻塞,线程执行完后,get才能返回
2,ReentrantLock
ReentrantLock 也是可重⼊锁
private static int count=0; public static void main(String[] args) throws InterruptedException { ReentrantLock locker=new ReentrantLock(); Thread thread=new Thread(()->{ for (int i = 0; i < 100; i++) { locker.lock(); count++; locker.unlock(); } }); Thread thread2=new Thread(()->{ for (int i = 0; i < 100; i++) { locker.lock(); count++; locker.unlock(); } }); thread.start(); thread2.start(); thread.join(); thread2.join(); System.out.println(count); }
synchronized与ReentrantLock的区别:
1)synchronized是属于关键字(底层是通过JVM的c++代码实现的),但是ReentrantLock是一个右Java标准库提供的类,它是由Java实现的
2)synchronized是通过代码块实现加锁解锁的,ReentrantLock是通过调用lock/unlock来实现的,这就导致可能会忘记解锁
3)ReentrantLock提供了tryLock这样的加锁风格,之前的锁,都是发现别人占用了之后等待阻塞,但是tryLock在加锁失败的情况下,不会阻塞,而是直接返回值来反馈是加锁成功还是失败了
4)ReentrantLock提供了公平锁的机制,默认也是非公平锁,但可以在构造方法中传入参数,设定为公平的
5)ReentrantLock有更强的"等待通知机制",基于Condition类,能力比wait/notify强大
3,信号量Semaphore
信号量就是一个"计数器,通过计数器衡量"可用资源"的个数,申请(acquire)资源让计数器加1,也称为"P 操作".释放(release)资源让计数器减1,也称之为"V操作",如果资源为0,继续申请,就会出现阻塞.
Semaphore semaphore=new Semaphore(3); semaphore.acquire(); System.out.println("申请一个资源"); semaphore.acquire(); System.out.println("申请一个资源"); semaphore.acquire(); System.out.println("申请一个资源"); semaphore.release(); System.out.println("释放一个资源"); semaphore.acquire(); System.out.println("申请一个资源");
当信号量为1 的时候,就相当于"锁",这时资源数为1/0,也称二元信号量
private static int count=0; public static void main(String[] args) throws InterruptedException { Semaphore semaphore=new Semaphore(1); Thread thread=new Thread(()->{ for (int i = 0; i < 100; i++) { try { semaphore.acquire(); count++; semaphore.release(); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); Thread thread2=new Thread(()->{ for (int i = 0; i < 100; i++) { try { semaphore.acquire(); count++; semaphore.release(); } catch (InterruptedException e) { throw new RuntimeException(e); } } }); }
4,CountDownLatch
需要把一个大的任务,拆分成小的任务,通过多线程/线程池执行,如何衡量所有任务都执行完毕了
比如:多线程下载,网络上下载的是单线程,但是想要提高下载的总速率,就可以使用专门的下载工具,通过和服务器建立多个网络连接,创建出多个线程就可以大大提高下载的总速率
public static void main(String[] args) throws InterruptedException { ExecutorService executorService= Executors.newFixedThreadPool(4); CountDownLatch countDownLatch=new CountDownLatch(20);//参数为任务数 for (int i = 0; i < 20; i++) { int id=i; executorService.submit(()->{ System.out.println("下载任务"+id+"开始"); try { Thread.sleep(3000); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("下载任务"+id+"结束"); countDownLatch.countDown(); }); } countDownLatch.await();//相当于all wait.收到20个"完成,代表所有任务都结束了 System.out.println("所有任务都完成了"); }
CountDownLatch由此,就可以衡量出当前任务是否完整执行结束
四,线程安全的集合类
Vector, Stack, HashTable...是线程安全的(不建议⽤), 其他的集合类不是线程安全的
解决方案:
1)自己加锁
2)使用带锁的List
如果需要使用ArrayList/LinkedList这样的结构标准库中提供了了带锁的List
3)使用CopyOnWrite集合类
这个集合类是不加锁的,但是通过"写实拷贝"来实现线程安全(来避免两个线程同时改变一个变量).
例如一个ArrayList,如果只是对这个进行读操作,则不进行任何改变,但是如果有其他线程要改变表中的数据,那么就会将这个ArrayList上面的元素重新拷贝到一个新的空间上,然后对新的ArrayList进行修改.(修改的时候,读操作还是读取的旧ArrayList里面的数据),修改完成后,将ArrayList的引用指向新的建立ArrayList的空间(这个赋值的操作是原子的).由此操作能够确保读到的是有效的数据,要么是新数据,要么是旧数据,不会读到只修改了一半的数据.
缺点:
(1)如果针对多个线程同时写的话,该方法就难以应对,这种方法主要针对一个线程读,一个线程写的场景.
(2)如果涉及的数据量很大,拷贝起来就会非常慢
3)多线程下使用队列
4)ConcurrentHashMap
多线程环境下使用哈希表HashMap/hashtable,推荐使用ConcurrentHashMap,相比前两者改进力度非常大.
(1)优化了锁的粒度(核心).
hashtable的加锁,就是直接给put/get等方法直接加上synchronized.整个哈希表对象就是一把锁,就会引起的锁竞争
ConcurrentHashMap是给每一个hash表中的"链表"进行加锁(不是一把锁,而是多把锁),这种方法可以保证线程是安全的,大大降低锁冲突的概率,只有同时进行两次修改,恰好修改的值在同一个链表上,才会触发锁冲突.
(2)ConcurrentHashMap引入了CAS这样的操作,针对修改size这样的操作,这样就不会加锁了
(3)针对读操作,做了特殊的处理,上述加锁,只针对写操作加锁,对于读操作,只是通过volatile以及一些精巧的代码实现,确保读操作不会读到"修改一半的数据"
(4)针对hash表的扩容进行了优化
普通的hash表扩容,是需要建立新的hash表,把元素搬过去,这一系列操作很肯能再一次普通中就完成了,这就会使out的开销非常的大,耗时长.但是ConcurrentHashMap是进行了"化整为零",不会在一次操作中将所有数据搬运,而是一次只搬运一部分.此后,每一次操作都会触发一部分key的搬运,最终把所有key都搬完成
当新旧表同时在的时候,插入操作是直接插入到新的空间中,查询/修改/删除,都是需要同时查询新旧表