Java共享问题 、synchronized 线程安全分析、Monitor、wait/notify以及锁分类

1.共享带来的问题

(1)两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

static int counter = 0;
public static void main(String[] args) throws InterruptedException {
	Thread t1 = new Thread(() -> {
		 for (int i = 0; i < 5000; i++) {
 			counter++;
 		}
	}, "t1");
 	Thread t2 = new Thread(() -> {
 		for (int i = 0; i < 5000; i++) {
 			counter--;
 		}
 	}, "t2");
 	t1.start();
	t2.start();
 	t1.join();
 	t2.join();
 	log.debug("{}",counter);
}

(2)以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
例如:
①对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

②而对应 i-- 也是类似:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i

(3)如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

在这里插入图片描述

(4)出现负数的情况
在这里插入图片描述

(5)出现正数的情况:

在这里插入图片描述

1.1 临界区 Critical Section

(1)一个程序运行多个线程本身是没有问题的
(2)问题出在多个线程访问共享资源
①多个线程读共享资源其实也没有问题
②在多个线程对共享资源读写操作时发生指令交错,就会出现问题
(3)一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
(4)例如,下面代码中的临界区

static int counter = 0;
public static void main(String[] args) throws InterruptedException {
	Thread t1 = new Thread(() -> {
		for (int i = 0; i < 5000; i++)
		// 临界区
 		{
		counter++;
		}
 	}, "t1");
 	Thread t2 = new Thread(() -> {
 		for (int i = 0; i < 5000; i++)
 		// 临界区
 		{
 		counter--;
 		}
 	}, "t2");
 	t1.start();
 	t2.start();
 	t1.join();
 	t2.join();
 	log.debug("{}",counter);
}

1.2 竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。为了避免临界区的竞态条件发生,有多种手段可以达到目的。
(1)阻塞式的解决方案:synchronized,Lock
(2)非阻塞式的解决方案:原子变量
(3)synchronized,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
(4)虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
①互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
②同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

2. synchronized语法及理解

(1)语法

synchronized(对象) // 线程1, 线程2(blocked)
{
 临界区
}

(2)理解
在这里插入图片描述
①synchronized(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人
②当线程 t1 执行到 synchronized(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++ 代码
②这时候如果 t2 也运行到了 synchronized(room) 时,它发现门被锁住了,只能在门外等待,自身发生了上下文切换,由运行阶段变为阻塞状态
③这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
④当 t1 执行完 synchronized{} 块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码

2.1 方法上的 synchronized

class Test{
 public synchronized void test() {
 
 }
}
等价于
class Test{
 public void test() {
	 synchronized(this) { // 锁的是this对象

	 }
  }
}
class Test{
 public synchronized static void test() {
  }
}
等价于
class Test{
 public static void test() {
 	synchronized(Test.class) {
 
 	}
  }
}

3.变量的线程安全分析

3.1.成员变量和静态变量是否线程安全?

(1)如果它们没有共享,则线程安全
(2)如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
①如果只有读操作,则线程安全
②如果有读写操作,则这段代码是临界区,需要考虑线程安全

3.2.局部变量是否线程安全?

(1)局部变量是线程安全的
(2)但局部变量引用的对象则未必
①如果该对象没有逃离方法的作用访问,它是线程安全的
②如果该对象逃离方法的作用范围,需要考虑线程安全

3.2.1 局部变量线程安全分析

public static void test1() {
 int i = 10;
 i++;
}

(1)每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享。如图:
在这里插入图片描述
(2)局部变量引用的对象则稍有不同
①先看一个成员变量的例子

class ThreadUnsafe {
	ArrayList<String> list = new ArrayList<>();
 	public void method1(int loopNumber) {
 		for (int i = 0; i < loopNumber; i++) {
 			// { 临界区, 会产生竞态条件
 			method2();
 			method3();
  			// } 临界区
 		}
	 }
 	private void method2() {
 		list.add("1");
 	}
 	private void method3() {
 		list.remove(0);
 	}
}

执行
其中一种情况是,如果线程2 还未 add,线程1 remove 就会报错:

Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0 
	at java.util.ArrayList.rangeCheck(ArrayList.java:657) 
 	at java.util.ArrayList.remove(ArrayList.java:496) 
 	at cn.itcast.n6.ThreadUnsafe.method3(TestThreadSafe.java:35) 
 	at cn.itcast.n6.ThreadUnsafe.method1(TestThreadSafe.java:26) 
 	at cn.itcast.n6.TestThreadSafe.lambda$main$0(TestThreadSafe.java:14) 
 	at java.lang.Thread.run(Thread.java:748) 

分析:
无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
method3 与 method2 分析相同

②将 list 修改为局部变量那么就不会有上述问题了

class ThreadSafe {
 	public final void method1(int loopNumber) {
 		ArrayList<String> list = new ArrayList<>();
 		for (int i = 0; i < loopNumber; i++) {
 			method2(list);
 			method3(list);
	 }
 }
 	private void method2(ArrayList<String> list) {
 		list.add("1");
 	}
 	private void method3(ArrayList<String> list) {
 		list.remove(0);
 	}
}

分析:
list 是局部变量,每个线程调用时会创建其不同实例,没有共享
而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
method3 的参数分析与 method2 相同

4.Monitor

4.1 Java 对象头

(1)java的对象头由以下三部分组成:
①Mark Word
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

在这里插入图片描述

②指向类的指针
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Java对象的类数据保存在方法区。
③数组长度(只有数组对象才有)
只有数组对象保存了这部分数据。该数据在32位和64位JVM中长度都是32bit。
(2)普通对象
在这里插入图片描述
(3)数组对象
在这里插入图片描述

4.2 Monitor概念

(1)Monitor被翻译为监视器或管程(由操作系统提供)
(2)每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就会被设置指向Monitor对象的指针
(3)Monitor的结构如下:
在这里插入图片描述
①刚开始Monitor中Owner为null
②当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
③在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
④Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时候是非公平的
注意:
①synchronized必须是进入同一个对象的monitor才有上述的效果
②不加synchronized的对象不会关联监视器,不遵从以上规则

5.synchronized原理

5.1 轻量级锁

轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是synchronized。例如:
在这里插入图片描述

  • 加锁
    (1)方法被调用时会产生一个栈帧,线程0执行到method1()的synchronized(obj)时会在线程的栈帧中创建锁记录(Lock Record)对象(该对象对我们是不可见的,是JVM层面的),每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
    在这里插入图片描述

(2)让锁记录中的Object reference指向锁对象,尝试用cas把锁记录中的数据和锁对象中的Mark Word做一个交换,交换是为了表示加锁。
在这里插入图片描述

①如果cas替换成功,对象头中存储了锁记录地址状态00,表示由该线程给对象加锁,这时图示如下
在这里插入图片描述
②如果cas失败,有两种情况:
一种是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程;
另一种是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
在这里插入图片描述

  • 解锁
    (1)当退出synchronized代码块(解锁时),如果有取值为null的锁记录,表示有重入,这时重置锁记录,重入计数减一
    在这里插入图片描述
    (2)当退出synchronized代码块(解锁时),锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
    ①成功,则解锁成功
    ②失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

5.2 锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁
在这里插入图片描述
(1)当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁

在这里插入图片描述

(2)这时Thread-1加轻量级锁失败,进入锁膨胀流程。
①即为Object对象申请Monitor锁,让Object执行指向重量级锁地址。
②然后自己进入Monitor的EntryList BLOCKED
在这里插入图片描述
(3)当Thread-0退出同步代码块解锁时,使用cas将Mark Word的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程

5.3 自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功,这时当前线程就可以避免阻塞。(自旋即让这个线程先不进入阻塞,而是进行几次循环,如果在循环的过程持锁线程已经退出了同步块释放了锁,就可以避免阻塞)
(1)自旋重试成功和失败的情况
①自旋重试成功
在这里插入图片描述
②自旋重试失败
在这里插入图片描述
(2)在Java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,是比较智能的。
(3)自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
(4)Java7之后不能控制是否开启自旋功能。

5.4 偏向锁

轻量级锁在没有竞争时(只有自己这个线程),每次重入仍然需要执行CAS操作。Java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己就表示没有竞争,不用重新CAS,以后只要不发生竞争,这个对象就归该线程所有
例如:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

5.4.1 偏向状态

在这里插入图片描述
(1)一个对象创建时:
①如果开启了偏向锁(默认开启),那么对象创建后,markword值位0x05即最后3位为101,这时它的thread、epoch、age都为0
②偏向锁是默认延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数
-XX:BiasedLockingStartupDelay=0来禁用延迟
③如果没有开启偏向锁,那么对象创建后,markword值为0x01即最后3位为001,这时它的hashcode、age都为0,第一次用到hashcode时才会赋值
(2)禁用偏向锁
添加VM参数 -XX:-UseBiasedLocking禁用偏向锁

5.4.2 撤销偏向锁

5.4.2.1 撤销-调用对象hashCode

调用对象的hashCode()方法,会禁用掉偏向锁。因为如果处于偏向锁的对象头只能存线程ID,存不下哈希码了
在这里插入图片描述

5.4.2.3 撤销-其他线程使用对象

当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁

5.4.2.4 撤销- 调用wait/notify

只有重量级锁才有wait/notify方法

5.4.3 批量重偏向

(1)如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
(2)当撤销偏向随的阈值超过20次后,jvm会觉得是不是偏向错了,于是会在给这些对象加锁时重新偏向至加锁线程

5.4.4 批量撤销

当撤销偏向锁阈值超过40次后,jvm就会这样觉得,自己是不是偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

5.4.5 锁消除

锁消除即删除不必要的加锁操作。JVM在运行时,对一些“在代码上要求同步,但是被检测到不可能存在共享数据竞争情况”的锁进行消除。

5.4.6 锁粗化

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

6.wait/notify

6.1 wait/notify原理

在这里插入图片描述
(1)线程获取某个对象的Monitor锁,Owner线程发现条件不满足,调用wait方法,即可进入WaiSet变为WAITING状态
(2)BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
(3)BLOCKED线程会在Owner线程释放锁时唤醒
(4)WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入EntryList重新竞争

6.2 API介绍

  • obj.wait() 让已经进入 object 监视器的线程到 waitSet 等待
  • obj.notify() 让object 上正在 waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法

final static Object obj = new Object();
public static void main(String[] args) {
	new Thread(() -> {
 		synchronized (obj) { // 必须获得此对象的锁,才能调用API方法
 			log.debug("执行....");
 			try {
 				obj.wait(); // 让线程在obj上一直等待下去
 			} catch (InterruptedException e) {
 				e.printStackTrace();
 			}
 			log.debug("其它代码....");
		}
 	}).start();
 	new Thread(() -> {
 		synchronized (obj) {
 			log.debug("执行....");
 			try {
 				obj.wait(); // 让线程在obj上一直等待下去
 			} catch (InterruptedException e) {
 				e.printStackTrace();
 			}
 			log.debug("其它代码....");
 		}
 	}).start();
 	// 主线程两秒后执行
 	sleep(2);
 	log.debug("唤醒 obj 上其它线程");
 	synchronized (obj) {
 		obj.notify(); // 唤醒obj上一个线程
 		// obj.notifyAll(); // 唤醒obj上所有等待线程
	}
}

6.3 wait、notify 的正确使用

(1)sleep(long n) 和 wait(long n) 的区别
①sleep是 Thread 方法,而 wait 是 Object 的方法
②sleep不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
③sleep在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
④ 它们的状态都是TIMED_WAITING

7.锁分类

7.1 乐观锁和悲观锁

7.1.1 乐观锁

(1)认为自己在使用数据的时候不会有别的线程修改数据或资源,不会添加锁,Java中使用无锁编程来实现,只是在更新的时候去判断之前有没有别的线程更新了这个数据,如果这个数据没有被更新,当前线程将自己修改的数据成功写入,如果已经被其他线程更新,则根据不同的实现方式执行不同的操作,比如:放弃修改、重试抢锁等等。
(2)判断规则有:
①版本号机制Version
②最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
(3)适合读操作多的场景,不加锁的特性能够使其读操作的性能大幅提升,乐观锁则直接去操作同步资源,是一种无锁算法

7.1.2 悲观锁

(1)认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改,synchronized和Lock的实现类都是悲观锁
(2)适合写操作多的场景,先加锁可以保证写操作时数据正确,显示的锁定之后再操作同步资源

7.2 公平锁和非公平锁

(1)公平锁:是指多个线程按照申请锁的顺序来获取锁,这里类似于排队买票,先来的人先买,后来的人再队尾排着,这是公平的。例如:Lock lock = new ReentrantLock(true)—表示公平锁,先来先得。
(2)非公平锁:是指多个线程获取锁的顺序并不是按照申请的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级反转或者饥饿的状态(某个线程一直得不到锁)。例如:Lock lock = new ReentrantLock(false)—表示非公平锁,后来的也可能先获得锁,默认为非公平锁。
(3)为什么会有公平锁/非公平锁的设计?为什么默认非公平?
①恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能更充分地利用CPU的时间片,尽量减少CPU空闲状态时间。
②使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当一个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得很大,所以就减少了线程的开销。
(3)什么时候用公平?什么时候用非公平?
如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省了很多线程切换的时间,吞吐量自然就上去了;否则就用公平锁,大家公平使用。

7.3 可重入锁

(1)是指在同一线程在外层方法获取到锁的时侯,在进入该线程的内层方法会自动获取锁(前提,锁对象的是同一个对象),不会因为之前已经获取过还没释放而阻塞---------优点之一就是可一定程度避免死锁。
(2)可重入锁种类
①隐式锁(即synchronized关键字使用的锁),默认是可重入锁
○ 在一个synchronized修饰的方法或者代码块的内部调用本类的其他synchronized修饰的方法或者代码块时,是永远可以得到锁。
②显式锁(即Lock),有ReentrantLock这样的可重入锁

  • 18
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值