1.乐观锁与悲观锁
这是锁的一种特性,指的是一类锁,不是一种锁。悲观乐观是对后续所冲突的预测。
如果预测将来发生锁冲突的概率不大,就可以少做一些工作,称为“乐观锁”;
如果预测将来发生锁冲突的概率较大,就会多做一些工作,称为“悲观锁”;
2.重量级锁与轻量级锁
轻量级锁(乐观锁就是一种)通常开销比较小,而重量级锁(悲观锁就是一种)开销比较大。
3.自旋锁与挂起等待锁
自旋锁(轻量级锁),往往是纯用户态的操作。比如通过while不停地判断当前锁是否被释放,如果没有释放就继续循环,否则则跳出循环。
而挂起等待锁(重量级锁)通过调用系统api来实现,一旦出现锁竞争就会出现阻塞等待的一系列动作(阻塞的开销是很大的)。
4.可重入锁与不可重入锁
一个线程连续针对一把锁进行加锁两次,如果没有产生阻塞就称为可重入锁,否则为不可重入锁。同时写都需要进行加锁。
5.读写锁
把加锁分为读锁和写锁,只有两个线程同时读不需要加锁(此时不会出现线程安全问题),两个线程一个读一个写或者是同时写。
6.非公平锁与公平锁
当一个线程已经获取到一把锁。当释放该把锁之后,其他线程获取是按照“先来后到”的方式获取还是均等的概率获取?
采用前一种就说明是公平锁,后一种就是非公平锁。
7.CAS(compare and swap)
本义就是比较和交换(比较交换的是内存与寄存器),这个操作是原子的。
eg:
CAS(M,A,B):
M:内存 A,B:寄存器
当M和A里面的值相同:交换M与B里面的值;
当M和A里面的值不相同,则无事发生。
所以CAS交换的本质就是把B里面的值赋值给M里面的值(也就是更关心内存(M)中的值)。
(1)本质:
就是一个cpu的指令,单个cpu指令是原子的,通过CAS操作就可以替代一些需要加锁的时候。
(2)别名:
无锁编程:基于CAS实现的线程安全的方式。
优点:无需加锁且避免造成阻塞
缺点:代码更复杂;只能在一些特定的场景下使用,不如直接加锁朴实
(3)实现原子类:
常用的就是AtomicInteger类:基于CAS方式对int进行封装,里面的++操作就是原子的(一步到位)
前面产生的线程安全本质上是因为在进行自增的过程中,就别的线程穿插进来了,可能造成比较严重的后果。
而CAS也是进行自增,也会有线程穿插进来(通过值是否发生变化来进行判断,也就 CAS(M,A,B)中M与A中的值是否相等),如果有穿插进来的线程就会进行重试(while判断)来避免穿插造成的影响。
(4)其他方法:
基于CAS实现自旋锁:有lock和unlock(对默认创建的线程置为null)两方法
如果当前锁已经被其他线程获取了,就自旋等待(通过while循环的方式);
如果当前锁没有被其他线程获取,就将默认创建的线程(自定义)设置为获取当前锁的线程。
(5)ABA问题:
CAS进行操作的关键是通过判断值是否发生变换来判断当前是否有线程穿插进来。但往往这是不严谨的,更极端的情况下,可能有另一个线程穿插进来,造成A-B-A的后果,此时前一个线程并不知道当前这个值是否发生变化(看起来好像是这个值,但是实际上已经被其他线程穿插进来了)
eg:
有增有减就可会出现ABA问题;;只增只减不会出现ABA问题;
但是像账户余额的这种场景本身就要能够能增能减,此时可以引入一个额外的变量:版本号,约定每次修改的时候都要让版本号自增,此时使用CAS判定是的时候就是通过判定版本号的自增来判断是否有线程穿插进来,如果不变则没有线程穿插进来。
8.synchronized原理
(1)基本特点:
a.乐观/悲观,是自适应的。
b.轻量级/重量级,是自适应的。
c.自旋/挂起等待,是自适应的。
如果预测接下来发生冲突的概率不怎么大,那么此时就为乐观/轻量级/自旋锁。
如果预测接下来发生冲突的概率较大,那么此时就会从乐观锁升级成悲观/重量级/挂起等待锁。
d.是可重入的
e.是非公平锁
f.不是读写锁
(2)几个重要特性:
a.锁升级
也就是从无锁-偏向锁-轻量级锁-重量级锁的过程。
而什么是偏向锁呢?
核心思想:是“懒汉模式”的另一种体现。
偏向锁并不是真正对锁进行加锁,而是说做一标记(能不加锁就尽量不加锁,因为加锁就意为着会导致冲突,冲突就意为着有开销),在运行的时候,做出判断,如果当前发生锁冲突的概率较小,就会变为轻量级锁,如果当前发生锁冲突的概率比较大,就会升级为重量级锁。)
b.锁消除(是在编译阶段做出的一个操作)
是编译器进行的一个优化操作,它会根据当前的编译环境,做出判定,如果编译器觉得当前这个场景不需要加锁,就会把锁的这个操作给优化掉。(编译器只有在绝对确定的情况下,才会进行这个操作,所以这个操作的触发率是比较低的)
eg:
StringBuilder:线程不安全的(没加锁)
StringBuffer:线程安全的(加锁)
但是在单线程环境下,用StringBuffer的话编译器会自动把加锁的操作给优化掉。
c.锁粗化
锁的粗化用锁的粒度来进行描述。
synchronized中代码越少,锁的粒度越细;代码越多,锁的粒度越粗。
锁的粒度越细,并发执行的任务就能越多,cpu的资源利用也更多,但是同时可能意味着要反复地加锁释放锁,可能实际效果还不如锁粒度粗的锁。
9.JVC(java.util.concurrent包)中的常见类
(1)ReentrantLock
ReentrantLock是一个可重入锁使用效果与synchronized类似
独特之处:
a.有两种方式加锁:lock(会死等,同synchronized一样)和tryLock(如果想要获取一把锁,等待一段时间后如果还没有获取到就放弃)方式,但是最后还需要手动调用方法进行解锁(unlock方法)
b.ReentrantLock提供公平锁的实现方式(默认是非公平锁)
c.ReentrantLock提供了更强大的等待通知机制,是搭配Condition类来进行实现的,可以实现更精准地唤醒一个指定地线程。
虽然ReentrantLock有上述优势,但是在实际使用中还是直接使用synchronized,前者比较麻烦,尤其是容易忘记解锁。
(2)CountDownLatch
主要适用于,多个线程在执行多个任务的时候,用来衡量任务的进度是否完成。比如现在有一个大的任务需要把它拆成多个小任务,此时就可以用CountDownLatch去判定当前任务是否已经完成。
有两个主要的方法:
a.await:
一旦调用就需要等待所有线程的任务都执行完成后才能进行后续的代码执行。
b.countDown:
告诉CountDownLatch,当前子任务已经完成。
(3)Semaphone(信息量)
其实就是一个计数器,用来描述“可用资源”的个数。
申请资源(P操作)就会使计数器减一;释放资源(V操作)就会使计数器加一。
synchronized就是一个可用资源数为1的信息量:
加锁:信息量从1-0;释放锁:信息量从0-1
所以可以看出锁就是一个特殊的信息量。
10.线程安全集合类
针对一些不安全的集合类,但是想在多线程环境下使用。
(1)多线程环境使用ArrayList
a.通过加锁/java标准库中提供的组件来确保线程安全
eg:collections.synchronized(new ArrayList())
这个方法会返回一个新的List对象,此时这个List就是线程安全的了。
b.CopyOnWriteArrayList(实时拷贝)
eg:当两个线程使用同一个ArrayList的时候,可能会读,会写。如果是读的话直接读就可以了不会产生线程安全的问题。如果是修改的话,会将原先的ArrayList拷贝出一份副本,修改的话是修改这个副本,而不是对原先数据进行修改,此时如果另一个线程想要读的话,也是可以的,读的就是原先ArrayList中的数据,第一个线程修改完成后,会将原来的数据给替换成新修改以后的(这个过程通常是引用赋值)。
使用时需注意:ArrayList中的数据量不能过大(拷贝成本不能过大);更适合于一个线程去修改,不适合于多个线程去修改。
(2)多线程环境使用哈希表
a.HashTable:
实现线程安全是通过给方法加上synchronized,相当于给当前对象进行加锁,如果两个线程同时访问一个线程就会出现冲突(一个hash表一把锁)。
一旦某一个线程在操作过程中触发扩容,就会由该线程完成整个扩容过程,在扩容过程中会拷贝全部数据,效率是低下的。
b.concurrentHashMap(使用方法基本于HashMap一致)
相较于HashTable,最核心的改进(1.8之后)就是每个链表(哈希桶)一把锁(用每一个链表的头节点作为锁对象),这样只有两个线程访问同一个哈希桶的时候才会出现锁冲突。
优点:
* 每个链表一把锁
* 充分利用CAS的特性,把一些不必要的加锁操作给省去了,避免形成重量级锁。
* 不会对读操作加锁,只会对写操作加锁。两个线程可以同时读,不能同时写,但是会不会出现到一个修改一半的数据(写的时候来读)?答案是不会的,因为concurrentHashMap中的操作都是原子的(比如直接通过=来赋值),不会像之前的++/--这些非原子操作,所以不会存在读到一个写了一半的数据。
* HashTable扩容时是一个线程完成整个数据的拷贝操作,而concurrentHashMap的扩容分多个线程来执行的,不是一次性全部拷贝完成,而是分多次去拷贝(每个线程操作concurrentHashMap的时候都会搬运一点,直到最后一个元素从老数组中拷贝到新数组中,新数组是触发扩容是第一个线程所创建的,后续来操作concurrentHashMap的线程只需要进行扩容),避免在一次拷贝的过程中出现卡顿。