并发编程(三):线程安全问题分析、Synchronized使用、Synchronized底层原理及优化
本文目录
一、线程安全问题
线程出现问题的根本原因是因为线程上下文切换,导致线程里的指令没有执行完就切换执行其它线程了,下面举一个例子
1.Java小例子
线程t1、t2分别对count加1、减1 5000次,日志输出最终的count值。最终发现count值不为0。
public class Test {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 1; i < 5000; i++){
count++;
}
});
Thread t2 =new Thread(()->{
for (int i = 1; i < 5000; i++){
count--;
}
});
t1.start();
t2.start();
t1.join(); // 主线程等待t1线程执行完
t2.join(); // 主线程等待t2线程执行完
// main线程只有等待t1, t2线程都执行完之后, 才能打印count, 否则main线程不会等待t1,t2
// 直接就打印count的值为0
log.debug("count的值是{}",count);
}
}
原因:静态变量count的,++和–字节码指令,有四条,不是原子的,有可能未执行完毕就发生了上下文切换。
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
以图解的方式举例
2.临界区
临界区的概念:一段代码内如果存在对共享资源的多线程读写操作,那么称这段代码为临界区; 共享资源也成为临界资源
- 一个程序运行多线程本身是没有问题的,问题出现在多个线程共享资源的时候;
- 多个线程同时对共享资源进行读操作本身也没有问题 - 对读操作没问题;
- 问题出现在对对共享资源同时进行读写操作时就有问题了 - 同时读写操作有问题。
3.竞态条件
多个线程在临界区执行,那么由于代码指令的执行不确定而导致的结果问题,称为竞态条件
二、Synchronized使用
为了避免临界区中的竞态条件发生,由多种手段可以达到
阻塞式解决方案: synchronized , Lock (ReentrantLock)
非阻塞式解决方案: 原子变量 (CAS)
讨论使用synchronized来进行解决,即俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程持有对象锁,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
1.加在对象上
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
// 对临界资源(共享资源的操作) 进行 加锁
synchronized (room) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",counter);
}
2.加在方法上
加在普通方法上,等价于加在this对象上
public class Demo {
//在方法上加上synchronized关键字
public synchronized void test() {
}
//等价于
public void test() {
synchronized(this) {
}
}
}
加在静态方法上,等价于加在类对象上
public class Demo {
//在静态方法上加上synchronized关键字
public synchronized static void test() {
}
//等价于
public void test() {
synchronized(Demo.class) {
}
}
}
3.线程八锁
三、变量的线程安全分析
1.成员变量和静态变量 分析
- 如果变量没有在线程间共享,那么变量是安全的。
- 如果变量在线程间共享:
如果只有读操作,则线程安全;
如果有读写操作,则这段代码是临界区,需要考虑线程安全。
成员变量作为共享资源,存在读写操作,可能存在线程安全问题
public class Test15 {
public static void main(String[] args) {
ThreadUnsafe unsafeTest = new ThreadUnsafe();
for (int i =0;i<100;i++){
new Thread(()->{
unsafeTest.method1();
},"线程"+i).start();
}
}
}
class ThreadUnsafe{
ArrayList<String> arrayList = new ArrayList<>();
public void method1(){
for (int i = 0; i < 100; i++) {
method2();
method3();
}
}
private void method2() {
arrayList.add("1");
}
private void method3() {
arrayList.remove(0);
}
}
2.局部变量 分析
- 局部变量【局部变量被初始化为基本数据类型】是安全的
- 但局部变量引用的对象则未必 (要看该对象是否被共享且被执行了读写操作)
- 如果该对象没有逃离方法的作用范围,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
局部变量为基本类型: 每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享,线程安全。
public static void test1() {
int i = 10;
i++;
}
局部变量为引用:未逃离方法作用范围 局部变量属于线程私有,线程安全。
//线程安全的例子
public class TestThreadSafe {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadSafe test = new ThreadSafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + (i+1)).start();
}
}
}
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
}
局部变量为引用:逃离了方法作用范围 线程不安全
//为ThreadSafe 类添加子类,子类覆盖method2 或 method3方法
public class TestThreadSafe {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadSafeSubClass test = new ThreadSafeSubClass();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + (i+1)).start();
}
}
}
class ThreadSafe {
public final void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
}
class ThreadSafeSubClass extends ThreadSafe{
// @Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
如果将ThreadSafe类的method2和method3改成private或final,则子类无法重写这两个方法,将不会产生线程安全问题
3.常见线程安全类
- 常见的线程安全类有:
String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包下的类 - 这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的,例如:
Hashtable table = new Hashtable();
new Thread(()->{
// put方法增加了synchronized
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
3.1线程安全类的组合
//get是线程安全的,put也是线程安全的,但是组合就不是原子的了,会受到上下文切换的影响,因此非线程安全
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
3.2不可变类的线程安全性
给出一个非线程安全的例子,类为抽象类,存在抽象方法foo(),因为foo方法可以被重写,行为是不确定的,可能导致线程非安全,被称之为外星方法。
public abstract class Test {
public void bar() {
// 是否安全
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
foo(sdf);
}
public abstract foo(SimpleDateFormat sdf);
public static void main(String[] args) {
new Test().bar();
}
}
JDK中的String和Integer,都是不可变类,被final修饰,它们的方法都是线程安全的。例如在String类中就考虑到了这一点,String类是final的,子类不能重写它的方法。
4.例子:卖票问题、转账问题
卖票问题
package cn.itcast.n4.exercise;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.Vector;
@Slf4j(topic = "c.ExerciseSell")
public class ExerciseSell {
public static void main(String[] args) throws InterruptedException {
// 模拟多人买票
TicketWindow window = new TicketWindow(1000);
// 所有线程的集合(由于threadList在主线程中,不被共享,因此使用ArrayList不会出现线程安全问题)
List<Thread> threadList = new ArrayList<>();
// 卖出的票数统计(Vector为线程安全类)
List<Integer> amountList = new Vector<>();
for (int i = 0; i < 2000; i++) {
Thread thread = new Thread(() -> {
// 买票
int amount = window.sell(random(5));
// 统计买票数
amountList.add(amount);
});
threadList.add(thread);
thread.start();
}
for (Thread thread : threadList) {
thread.join();
}
// 统计卖出的票数和剩余票数
log.debug("余票:{}",window.getCount());
log.debug("卖出的票数:{}", amountList.stream().mapToInt(i -> i).sum());
}
// Random 为线程安全
static Random random = new Random();
// 随机 1~5
public static int random(int amount) {
return random.nextInt(amount) + 1;
}
}
// 售票窗口
class TicketWindow {
// 票总数
private int count;
public TicketWindow(int count) {
this.count = count;
}
// 获取余票数量
public int getCount() {
return count;
}
// 售票
//线程安全的代码public synchronized int sell(int amount) {
public int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
}
转账问题
package cn.itcast.n4.exercise;
import lombok.extern.slf4j.Slf4j;
import java.util.Random;
@Slf4j(topic = "c.ExerciseTransfer")
public class ExerciseTransfer {
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1000);
Account b = new Account(1000);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
a.transfer(b, randomAmount());
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
b.transfer(a, randomAmount());
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 查看转账2000次后的总金额
log.debug("total:{}", (a.getMoney() + b.getMoney()));
}
// Random 为线程安全
static Random random = new Random();
// 随机 1~100
public static int randomAmount() {
return random.nextInt(100) + 1;
}
}
// 账户
class Account {
private int money;
public Account(int money) {
this.money = money;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
// 转账
public void transfer(Account target, int amount) {
//线程安全的代码synchronized(Account.class) { //锁住Account类,因为涉及到A.money和B.money。
synchronized(this) {
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
}
四、Synchronized原理
1.Java对象头
以 32 位虚拟机为例
2.Monitor(锁)
Monitor 被翻译为监视器或管程,每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针。
Monitor 结构如下
- 刚开始 Monitor 中 Owner 为 null 当 Thread-2 执行 synchronized(obj) 就会将 Monitor的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-1,Thread-3,也来执行 synchronized(obj),就会进入EntryList BLOCKED
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的 图中 WaitSet 中的
- Thread-4是之前获得过锁,但条件不满足进入 WAITING 状态的线程,介绍wait-notify 时会分析
注意: synchronized 必须是进入同一个对象的 monitor 才有上述的效果
不加 synchronized的对象不会关联监视器,不遵从以上规则
它加锁就是依赖底层操作系统的 mutex相关指令实现, 所以会造成用户态和内核态之间的切换, 非常耗性能,因此,称为重量级锁。
- 在JDK6的时候, 对synchronized进行了优化, 引入了轻量级锁, 偏向锁, 它们是在JVM的层面上进行加锁逻辑, 就没有了切换的消耗~
3.Synchronized字节码
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
反编译后的字节码
注意:方法级别的 synchronized 不会在字节码指令中有所体现
五、Synchronized优化
1.轻量级锁
使用场景: 如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(没有竞争,如果有竞争就成了重量级锁),那么可以使用轻量级锁来进行优化。
使用方式: 通过锁记录的方式,多个线程交替进入临界区,语法不变依旧是synchronized。
举个例子: 主线程先调用方法1,再调用方法2,加了两次锁,但是不会同时加,时间是错开的。
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
- 创建锁记录(Lock Record)对象,线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。
- 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
- 如果 cas 替换成功,对象头中存储了锁记录地址和状态 00 ,表示由该线程给对象加锁,这时图示如下
- 如果 cas 失败,有两种情况
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
- 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
- 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象
头
成功,则解锁成功
失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
2.锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
static Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块
}
}
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
- 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为null,唤醒 EntryList 中 BLOCKED 线程
3.自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
- 自旋重试成功的情况
- 自旋重试失败的情况
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能
4.偏向锁
4.1偏向锁引入
- 在轻量级的锁中,我们可以发现,如果同一个线程对同一个对象进行重入锁时,也需要执行CAS替换操作,这还是有点耗时。
- 那么java6开始引入了偏向锁,将进入临界区的线程的ID, 直接设置给锁对象的Mark word, 下次该线程又获取锁, 发现线程ID是自己, 就不需要CAS了。以后只要不发生竞争,这个对象就归该线程所有。
public class Test {
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块A
m2();
}
}
public static void m2() {
synchronized (obj) {
// 同步块B
m3();
}
}
public static void m3() {
synchronized (obj) {
// 同步块C
}
}
}
对于轻量级锁和偏向锁
4.2偏向锁状态
- Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
- Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
- Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后2位为状态(00)
- Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,最后2位为状态(10)
4.3 加锁 前、中、后 对象头内容验证
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为> 101,这时它的thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
- 注意:处于偏向锁的对象解锁后,线程id仍存储于对象头中
public class TestForFriday {
// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws IOException {
Dog d = new Dog();
ClassLayout classLayout = ClassLayout.parseInstance(d);
new Thread(() -> {
System.out.println(("synchronized 前"));
System.out.println(classLayout.toPrintable());
synchronized (d) {
System.out.println("synchronized 中");
System.out.println(classLayout.toPrintable());
}
System.out.println("synchronized 后");
System.out.println(classLayout.toPrintable());
}, "t1").start();
}
}
运行结果(非上述代码的直接结果,这里为了方便展示,是处理后的,当然也可以根据上述代码的结果查看,信息比较多):
4.4 禁用偏向锁验证
在上面测试代码运行时在添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁
4.5 偏向锁撤销
A.调用hashCode:
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
B.其它线程使用偏向锁
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
C.调用wait notify
4.6 批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
4.7 批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
5.锁消除
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
public class JitTest {
static int x = 0;
public static void main(String[] args) {
int loopNumber = 1000000000;
long startTime1 = System.nanoTime();
for (int i = 0; i <loopNumber ; i++) {
method1();
}
long end1 = System.nanoTime();
System.out.println("未加锁运行时间:"+(end1-startTime1)+"ns");
long startTime2 = System.nanoTime();
for (int i = 0; i <loopNumber ; i++) {
method2();
}
long end2 = System.nanoTime();
System.out.println("已加锁运行时间:"+(end2-startTime2)+"ns");
}
public static void method1(){
x++;
}
// JIT 即时编译器
public static void method2(){
Object o = new Object();
synchronized (o) {
x++;
}
}
}
两者的时间基本一致,因为JIT 即时编译器会优化这段加锁过后的热点代码,经过逃逸分析,局部变量O对象不会发生逃逸,因此会被优化。
加入VM参数设置,禁用JIT优化“锁”相关之后,-XX:-EliminateLocks,加锁的时间将比未加锁大大增加,如下图。