乐观锁
一种抽象的感念,这个锁认为出现锁 竞争的概率比较低,(当前场景中,线程数目比较少,不太涉及竞争,就偶尔竞争一下)。认为冲突的概率不是很高,做的工作会更少一些,付出的成本也更低。
悲观锁
这个锁认为出现锁 竞争的概率比较大,(当前场景中,线程的数目比较多,很可能涉及竞争)。认为冲突的概率比较高,做的工作会更多一些,付出的成本也更高。 注:操作系统,提供的锁接口,Mutex(互斥量),就是一个典型的悲观锁。认为竞争会很大,一旦出现了锁竞争,就会让竞争失败的线程进行等待,什么时候被唤醒,就取决于调度器的实现。Mutex 也是重量级锁,加锁时遇到冲突,就会产生内核态和用户态的切换,以及线程的大量阻塞和调度,开销就会很大
synchronized 可以认为是悲观锁也可以认为是乐观锁,它是自适应的,它会根据具体的场景进行自我调节
读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。读写锁把读操作和写操作分开了,进一步降低了所冲突的概率。synchronized不是读写锁。
自旋锁(轻量级锁)
自旋锁又称轻量级锁,基于 CAS 实现可以实现自旋锁。加锁时遇到冲突,不会产生内核态和用户态的切换,会一直循环尝试获取锁,不涉及线程调度。
可重入锁
简单理解就在对一个线程加了锁后再加锁而不是发生死锁,就叫可重入锁。synchronized 就是一个可重入锁,如果对同一个线程加了两次锁,synchronized 内部持有当前是那个线程获取的锁,并维护了一个计数器,如果是同一个线程尝试再次获取锁不会阻塞等待,而是单纯计数器自增。如果释放锁,不是真的释放,而是计数器自减,遇到计数器为 0 的时候才真正释放锁。
死锁
死锁是多线程开发中典型的问题,同时也是很严重的问题,一旦发生死锁,程序就挂了。一个线程一把锁不会发生死锁,只有在多个线程多把锁才可能发生死锁(哲学家就餐问题)。 多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。所以死锁产生的核心原因是环路等待,又称循环等待。
关于教科书中死锁产生的四个必要条件:
- 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
- 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
- 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
- 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
个人认为循环等待是最重要的因素,因为每一锁都不能释放自己所占有的资源。而其他三个原因更多的是指锁的特性
解决死锁
- 银行家算法,学过计算机专业的同学应该都听说过,因为这个算法在工作中不太会用到,所以以我目前的知识水平暂时不能具体介绍
- 不要在加锁代码中尝试获取其他锁。这样做就意味着在代码里同一时刻只获取一把锁。
- 约定一定的顺序来加锁,
锁策略小结
CAS 介绍
CAS: 全称Compare and swap,字面意思:“比较并交换”,是一个原子性的操作,当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。可见 CAS 其实是一个乐观锁。
换句话说,CAS 就是原子的完成,从内存中进行比较,如果比较结果相同,就进行赋值、交换或其他操作
ABA 问题:例如,现在账户有1000元,线程1要进行转账50元时网络出现了问题卡了,于是用户重新操作,第二个线程重新执行转账50成功,然后第一个线程又恢复,这时 CAS 原子操作就会和账户一开始读取到的值对比(1000!=950),发现不相同,于是就不执行,转账结束符合预期。假如再次出现第三个线程,这个线程执行了入账50元执行成功,并在一开始的第一个线程网络卡了的期间执行的,这时第一个线程的CAS原子操作和账户一开始的读取到的值对比(1000=1000),就再次执行了转账。这样就出现问题了,转账了两次,进账一次,不符合预期,这就是 ABA 问题,解决这个问题的方法就是在读取的时候在添加一个读取时间的参数也作为对比(也可以是版本号)。
synchronized 背后的工作原理(重点)
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
图片来源:
无锁:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其他修改失败的线程会不断重试直到修改成功。
偏向锁:对象的代码一直被同一线程执行,不存在多个线程竞争,该线程在后续的执行中自动获取锁,降低获取锁带来的性能开销。偏向锁,指的就是偏向第一个加锁线程,该线程是不会主动释放偏向锁的,只有当其他线程尝试竞争偏向锁才会被释放。 偏向锁的撤销,需要在某个时间点上没有字节码正在执行时,先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁;如果线程处于活动状态,升级为轻量级锁的状态。
轻量级锁:轻量级锁是指当锁是偏向锁的时候,被第二个线程 B 所访问,此时偏向锁就会升级为轻量级锁,线程 B 会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。当前只有一个等待线程,则该线程将通过自旋进行等待。但是当自旋超过一定的次数时,轻量级锁便会升级为重量级锁;当一个线程已持有锁,另一个线程在自旋,而此时又有第三个线程来访时,轻量级锁也会升级为重量级锁。
重量级锁:指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。
JUC 包的常见类
ReentrantLock
ReentrantLock 是可重入互斥锁,这个把加锁 lock() 和解锁 unlock() 分开了。synchronized 是进入代码块加锁出代码块解锁。 所以:这就是为什么有了 synchronized 有时候还要用 ReentrantLock ,就因为有一个tryLock() 这个方法。
Semaphore
一个计数信号量,主要用于控制多线程对共同资源库访问的限制。类似于停车场显示实时车位的那个
线程池
线程池主要是为了解决“频繁创建和销毁线程的开销比较大”而引入的。把需要的线程提前准备好,放到池子里,需要用线程就从池子里取,而不是从系统中申请(从池子里去是纯用户态代码,从系统中申请涉及到用户态和内核态的切换,以及内核中的一些操作)。 线程池是一个"过渡方案"。现在有更优化的解决方案“协程”(Go,Python,更广泛的使用协程)
使用Executors创建线程池,比较简单,种类也比较优先。 使用ThreadPool创建线程池,比较复杂,种类更多。一般实际开发还是更鼓励使用ThreadPool,更丰富的完成线程池的创建~
线程安全的集合类
ArrayList, LinkedList,Queue, HashMap, TreeMap, HashSet, TreeSet 都不是线程安全的!!!
1.多线程环境下使用顺序表
a). 自己加锁。 b). Collections.synchronizedList 相当于在ArrayList 等集合类上套了一层壳,壳里使用synchronized来加锁。 c). CopyOnWriteArrayList 解决方案是让不同的线程,使用不同的变量(没加锁)。
⒉.多线程环境下使用队列
BlockingQueue(一头进,一头出) BlockDeque (两头进,两头出) 都是接口
3.多线程环境下使用哈希表
这里的面试常见问题是HashMap 和 HashTable 和 ConcurrentHashMap 区别?
HashTable, (不推荐使用),单纯使用一个synchronized 进行加锁。是针对了整个 HashTable 对象,坏处就是锁冲突的概率是非常高的。而 HashMap 线程不安全,所以推荐使用 ConcurrentHashMap
ConcurrentHashMap 内部针对多线程做出了一定的优化[推荐使用],它不是针对整个对象加一把锁,而是分成了很多把锁,每个链表/红黑树分配一把锁。(jdk1.7是分段锁)只有当两个线程恰好修改的是同一个链表/红黑树的时候才会涉及到锁冲突~
ConcurrentHashMap 内部广泛的使用了CAS操作,来提高效率。比如,获取元素个数的时候,没加锁,直接CAS。比如修改元素,获取对应的链表的下标的时候,也是用CAS。
ConcurrentHashMap 针对扩容进行了优化,Hash表的扩容是个麻烦事,需要把整个hash表拷贝一份。 如果是HashTable,某个线程t正好触发了扩容,这个t就倒霉了,就需要负责完成整个扩容过程 如果是ConcurrentHashMap,把扩容任务分散开了,类似于"蚂蚁搬家",就能够更平滑的进行过度。
|