[Java] synchronized的锁优化机制
目录
一 . 锁膨胀(锁升级)
二 . 锁消除
三 . 锁粗化
附加 :
Callable 接口
ReentrantLock
ReentrantLock 与 synchronized 的区别
Semaphore (信号量)
CountDownLatch
多线程下使用哈希表
1. HashTable
2 .ConcurrentHashMap
ConcurrentHashMap 优点
CopyOnWriteArrayList
一 . 锁膨胀(锁升级)
主要经过四个阶段 : 无锁 -> 偏向锁 -> 自旋锁 -> 重量级锁 , 根据锁竞争的激烈程度 , 逐步提升 , synchronized 能够 "自适应" , 自动转变.
好处 : 既保证了效率 , 又保证了线程安全
偏向锁是synchronized 内部的工作 , synchronized 会针对某个对象进行加锁. "偏向锁" 就是给这个对象头里做个标记. 如果有其他的线程也要进行加锁操作, 发现锁已经被标记过了 , "偏向锁" 就会升级成为 (自旋锁)轻量级锁.
自旋锁(轻量级锁) : 拿到锁的速度会很快 , 但是消耗大量的CPU , 自选的时候 , CPU 是快速空转的 , 如果竞争锁的线程较少 , 短时间内就会拿到锁 , 让CPU空转一会也没事 , 但是如果当前的锁竞争非常激烈 , 自旋锁就会升级为重量级锁.在内核中进行阻塞等待 . 意味着线程要暂时放弃CPU , 由内核进行后续调度.
二 . 锁消除
编译器的一种优化手段, 它会自动检测当前的代码是否有必要加锁 , 如果没有必要 , 但是程序猿写了 , 就会在编译过程中自动把锁去掉. (非必要 , 不加锁)
例如 : StringBuffer 它是线程安全的 , 其中的关键方法都加上了synchronized , 但是如果是单线程使用 , 是不会涉及到线程安全的 , 加锁操作是不会被编译的.
三 . 锁粗化
粗 细 : 指的是加锁代码涉及到的范围 , 加锁的代码的范围小则认为粒度越细 , 范围越大 , 认为锁的粒度越粗.
那么粒度粗好还是细好 ?
答案是不一定的.
如果粒度比较细 , 那么多线程之间的并发性就更高.
如果粒度比较粗 , 那么加锁解锁的开销就更小.
编译器的优化 : 如果加锁之间间隔比较小(中间隔的代码较少) , 就会触发锁粗化的优化机制.
粒度比较细 , 确实会提高线程之间的并发性 , 但是频繁的加锁解锁这样的开销也是很大的.
举个栗子 : 在做一份数学练习题时 , 遇到一道不会 , 就跑到了老师的办公室 , 问她说 : "老师,A这道题我不会 , 你给我讲讲". 问完之后回到教室, 没过一会 , 又有一道不会 , 这时又跑到老师的办公室 , 问她说 : "老师 , B这道题我不会, 你给我讲讲". ........... 这样来来回回把时间都浪费在路上了.
经过优化后 , 先把这份题中不会的先圈出来 , 这样我们只用去一趟办公室 , 问一次就Ok了.
附加 :
Callable 接口
JUC(java.util.concurrent) - > Callable 就在这个包下.
Callable 跟Runnable 非常类似 , 都是来描述一个任务.
区别在于 : Callable 会有一个返回值. Runnable 是没有返回值的.
import java.util.concurrent.*;
public class Demo1 {
public static void main(String[] args) {
// 通过 callable 来描述一个这样的任务
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int count = 0;
for (int i = 0; i <= 100; i++) {
count += i;
}
return count;
}
};
// 为了让线程执行 callable 中的任务, 直接放到Thread()的构造方法中是不行的
// 还需要一个辅助类. FutureTask<>
FutureTask<Integer> task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
try {
// get 获取到的就是上述任务call 方法返回值的结果.
// get 和 join 类似 , 都是会阻塞等待 call方法执行完毕.
System.out.println(task.get());
} catch (InterruptedException e) { // 这个异常是在get阻塞的时候被打断了(任务还是继续正常执行)
e.printStackTrace();
} catch (ExecutionException e) { // 这个异常是任务执行过程中出现异常
e.printStackTrace();
}
}
}
ReentrantLock
ReentrantLock 可重入锁 , synchronized 也是可重入锁.
但 ReentrantLock 是把 加锁和解锁两个操作分开了.
lock() : 加锁 , unlock() : 解锁 .
这样的做法缺点就是很容易遗漏 unlock() , 一旦遗忘就会导致死锁.
更好的办法就是把unlock() 放到 finally 中. 保证不管是否出现任何异常都可以执行到unlock().
import java.util.concurrent.locks.ReentrantLock;
public class Demo2 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
int count = 0;
try {
reentrantLock.lock();
for (int i = 1; i <= 50; i++) {
count += i;
}
}finally {
reentrantLock.unlock();
}
System.out.println(count);
}
}
ReentrantLock 与 synchronized 的区别
ReentrantLock 与 synchronized 的区别 :
synchronized 关键字, 是对代码块进行加锁.
Reentrantlock 是将加锁解锁操作分开.
1. synchronized 是一个关键字(背后的逻辑是JVM 内部实现的) , ReentrantLock 是一个标准库中的类 (背后的逻辑是Java代码写的)
2. synchronized 不需要手动释放锁 , 出了代码块 , 锁自动释放 , ReentrantLock 必须要手动释放锁.
3. synchronized 如果出现了锁竞争, 就会阻塞等待 , 但是ReentrantLock 除了阻塞等待 , 还可以 trylock , 出现了锁竞争, 竞争失败就会直接返回.
4. synchronized 是一个公平锁 , RenntrantLock 提供了非公平和公平锁两个版本. 可以在构造方法中 , 通过参数来指定当前是公平锁还是非公平锁.(公平 : 遵循先来后到)
true : 表示是公平锁 . false : 表示是非公平锁.
5. 基于synchronized 衍生出来的等待机制 是 wait notify
基于ReentrantLock 衍生出来的等待机制 , 是 Condition 类 , 功能比起synchronized 更丰富
Semaphore (信号量)
是一个更广义的锁 , 锁是信号量里第一种特殊情况 , 叫做 "二元信号量"
P 操作 , 申请资源 , 计数器 -1
V 操作 , 释放资源 , 计数器 +1
acquire 申请
release 释放
import java.util.concurrent.Semaphore;
public class Demo3 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(4);
semaphore.acquire(3);
System.out.println("申请成功");
semaphore.acquire(1);
System.out.println("申请成功");
semaphore.release(4);
System.out.println("释放成功");
System.out.println("---");
}
}
如果计数器已经是0了 , 继续申请资源, 就会阻塞等待!
acquire会产生阻塞等待 , 但是release 不会
import java.util.concurrent.Semaphore;
public class Demo3 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(4);
semaphore.acquire(3);
System.out.println("申请成功");
semaphore.acquire(2);
System.out.println("申请成功");
System.out.println("---");
}
}
CountDownLatch
通过
控制了10个线程 ,
表示其中一个线程执行完毕.
主线程使用CountDownLatch.await() 方法 , 来阻塞等待所有任务完成.
10个线程每个线程执行完 , 都调用一个CountDownLatch.countDown方法 (表示当前线程执行完毕)
import java.util.concurrent.CountDownLatch;
public class Demo4 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName() + "到达终点!");
latch.countDown(); // 表示当前这个线程执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
// 需要等待10个线程全部都执行完了, await 才执行 , 否则就处于阻塞状态
latch.await();
}
}
多线程下使用哈希表
在多线程下 : HashMap 本身是线程不安全的
Java 提供了另外两个类 :
1. HashTable
2.ConcurrentHashMap
1. HashTable
如果元素多了 , 每个链表下的节点就会很多, 就需要进行扩容操作 , 从而增加数组的长度.
这时就要创建一个更大的数组 , 然后把之前的元素都给搬运过去 , 这是一个非常耗时的操作.
2 .ConcurrentHashMap
ConcurrentHashMap 是对每个数组下标的链表头进行加锁操作.
在使用ConcurrentHashMap时, 如果你两个线程操作是针对两个不同的链表上的元素, 这时它们之间是没有锁冲突的 , 就不存在线程安全问题.
由于hash 表中 , 链表的数目非常多 , 每个链表的长度是相对短的 , 因此锁冲突的概率就是非常小的.
ConcurrentHashMap 优点
1. ConcurrentHashMap 减少了锁冲突 , 就让锁加到每个链表的头结点上 (锁桶)
2. ConncurrentHashMap 只是针对 写操作加锁了 , 读操作没有加锁 , 而只是使用 .
3. ConcurrentHashMap 中更广泛的使用CAS, 进一步提高效率 (比如获取/更新元素个数)
4. ConcurrentHashMap 针对扩容, 不像HashTable 那样 一次直接全部搬运过去 ,
对于ConcurrentHashMap , 每次操作只搬运一点点 , 通过多次操作完成整个搬运的过程.
同时维护了旧的HashMap 和 新的 HashMap .这时如果要进行get 查找操作时 , 既需要查找新的, 也要查找旧的 , 如果要进行put 操作 , 就插入要新的HashMap中. 直到搬运完全部再销毁旧的.
CopyOnWriteArrayList
CopyOnWriteArrayList 是一个支持"写时拷贝" 的集合类.
如果是多个线程读 , 由于读本身就是线程安全 , 它是安全的.
如果此时有一个线程在尝试修改, 就会触发 写时拷贝 操作.
虽然不加锁 , 但是如果元素很多 , 拷贝的开销也就很大. 适用于 元素个数小 , 读多写少的情景.