Java并发编程——共享模型之管程
所谓管程(Monitor):指的是管理共享变量以及对共享变量的操作过程,让它们支持并发。
翻译为 Java 就是管理类的成员变量和成员方法,让这个类是线程安全的。
一、共享带来的问题
1.1 小故事
老王(操作系统)有一个功能强大的算盘(CPU),现在想把它租出去,赚一点外快
小南、小女(线程)来使用这个算盘来进行一些计算,并按照时间给老王支付费用
但小南不能一天24小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 io 操作),有时还需要一根烟,没烟时思路全无(wait)这些情况统称为(阻塞)
在这些时候,算盘没利用起来(不能收钱了),老王觉得有点不划算。另外,小女也想用用算盘,如果总是小南占着算盘,让小女觉得不公平。
于是,老王灵机一动,想了个办法 [ 让他们每人用一会,轮流使用算盘 ]。
这样,当小南阻塞的时候,算盘可以分给小女使用,不会浪费,反之亦然。
最近执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了 一个笔记本(主存),把一些中间结果先记在本上
计算流程是这样的
但是由于分时系统,有一天还是发生了事故
小南刚读取了初始值 0 做了个 +1 运算,还没来得及写回结果
老王说 [ 小南,你的时间到了,该别人了,记住结果走吧 ],于是小南念叨着 [ 结果是1,结果是1...] 不甘心地到一边待着去了(上下文切换)
老王说 [ 小女,该你了 ],小女看到了笔记本上还写着 0 做了一个 -1 运算,将结果 -1 写入笔记本
这时小女的时间也用完了,老王又叫醒了小南:[小南,把你上次的题目算完吧],小南将他脑海中的结果 1 写 入了笔记本
小南和小女都觉得自己没做错,但笔记本里的结果是 1 而不是 0
1.2 Java 的体现
两个线程对初始值为 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);
}
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理 解,必须从字节码来进行分析
例如对于 i++
而言(i 为静态变量),实际会产生如下的 JVM 字节码指令
getstatic i //获取静态变量i的值
iconst_1 //准备常量1
iadd //自增
putstatic i //将修改后的值存入静态变量i
而对应 i--
也是类似
getstatic i //获取静态变量i的值
iconst_1 //java准备常量1
isub //自减
putstatic i //将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:
但多线程下这 8 行代码可能交错运行:
出现负数的情况:
出现正数的情况:
1.3 临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
例如,下面代码中的临界区
static int counter = 0;
static void increment()
//临界区
{
counter++;
}
static void decrement()
//临界区
{
counter--;
}
1.4 竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
二、synchronized 解决方案
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:
synchronized
,Lock
- 非阻塞式的解决方案:原子变量
本次课使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
虽然 java中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
2.1 synchronized
语法
synchronized(对象) // 线程1, 线程2(blocked)
{
//临界区
}
案例
synchronized(对象)
中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人 进行计算,线程 t1,t2 想象成两个人- 当线程 t1 执行到
synchronized(room)
时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++
代码 - 这时候如果 t2 也运行到了
synchronized(room)
时,它发现门被锁住了,只能在门外等待,发生了上下文切 换,阻塞住了 - 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦), 这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才 能开门进入
- 当 t1 执行完
synchronized{}
块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥 匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的count--
代码
时序图
思考:
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断
为了加深理解,请思考下面的问题
- 如果把
synchronized(obj)
放在 for 循环的外面,如何理解?-- 原子性- 整个for循环体作为整个原子体
- 如果 t1
synchronized(obj1)
而 t2synchronized(obj2)
会怎样运作?-- 锁对象- 不会保护临界区代码,因为拿的不是同一个锁对象
- 保护共享资源,多个线程要保证锁住的是同一个对象
- 如果 t1
synchronized(obj)
而 t2 没有加会怎么样?如何理解?-- 锁对象
2.2 面向对象改进
把需要保护的共享变量放入一个类
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
@Slf4j
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count: {}" , room.get());
}
}
2.3 方法上的 synchronized
synchronized不能锁方法,本质上是锁的对象
1.加在成员方法上,锁的是this对象
class Test{
public synchronized void test() {
}
}
//等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
2.加在静态方法上,锁的是类对象
class Test{
public synchronized static void test() {
}
}
//等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
不加 synchronized 的方法
不加 synchronzied 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的)
2.4 线程八锁
其实就是考察 synchronized 锁住的是哪个对象
注意:sleep()会释放cpu资源,但是不会释放锁;wait()会释放锁
情况1:1->2 or 2->1
//a()、b()都加锁了,因为在同一个类下,所以都是给this加的锁
@Slf4j(topic = "c.Number")
class Number{
public synchronized void a() {
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
情况2:1s后1->2 or 2->1s后1
@Slf4j(topic = "c.Number")
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
情况3:3 -> 1s后1->2 or 2->3 -> 1s后1 or 3->2 -> 1s后1
3不可能在1之后
c()
未加锁,与a()、b()
不会有互斥的效果,并行执行;而a()、b()
会有互斥效果
@Slf4j(topic = "c.Number")
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
public void c() {
log.debug("3");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
new Thread(()->{ n1.c(); }).start();
}
情况4:2 -> 1s 后 1
因为锁的不是同一对象,因此两者不互斥,加上t1休眠1s
@Slf4j(topic = "c.Number")
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
//两个锁对象
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
情况5:2 -> 1s 后 1
static synchronized 锁住了类对象,类对象和this对象不是一个对象
a()、b()锁住的是不同的对象,中间没有互斥
@Slf4j(topic = "c.Number")
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
情况6:1s后1 -> 2 or 2 -> 1s后1
a()
、b()
都是静态,锁住的是Number类对象,因此n1.a()
且n1.b()
是互斥的
@Slf4j(topic = "c.Number")
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
情况7:2 -> 1s后1
锁住的仍然不是同一个对象
@Slf4j(topic = "c.Number")
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
情况8:1s后1 -> 2 or 2 -> 1s后1
a()
、b()
都是静态,锁住的是Number类对象,因此n1.a()
且n2.b()
是互斥的,虽然是不同对象,但是是一个类的
@Slf4j(topic = "c.Number")
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
三、变量的线程安全分析
3.1 成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
3.2 局部变量是否线程安全?
- 局部变量是线程安全的(局部变量是线程私有的)
- 但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
3.3 局部变量线程安全分析
public static void test1() {
int i = 10;
i++;
}
每个线程调用 test1()
方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
public static void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=0
0: bipush 10
2: istore_0
3: iinc 0, 1
6: return
LineNumberTable:
line 10: 0
line 11: 3
line 12: 6
LocalVariableTable:
Start Length Slot Name Signature
3 4 0 i I
如图
局部变量的引用稍有不同
成员变量的例子
基本数据类型可以直接存放在栈帧的局部变量中,而其他类对象在局部变量表中存放的是引用,实例在堆中。
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);
}
}
执行
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
因为是临界区,会产生竞态条件,所以出现一种情况:线程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 相同
方法内的对象,发生了栈逃逸,所以会在堆内存空间创建list对象,没有逃逸的话是在栈内存分配list对象的内存
逃逸分析:方法内部对象没有被外部引用或代码结束仍在方法内部,这种属于没有逃逸
方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代来线程安全问题?
- 情况1:有其它线程调用 method2 和 method3
- 其他线程直接调用method2 和 method3传过来的 list 与method1传进去的不是同一个,因此不会有问题
- 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即
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);
}
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
ThreadSafeSubClass extends ThreadSafe
,重写了父类method3,开辟了新线程,共享list,即出现了子类与父类共享资源,因此出现问题。
不能控制子类的行为,造成了线程安全的问题
从这个例子可以看出 private(限制子类不能重写父类) 或 final (不可继承)提供【安全】的意义所在,请体会开闭原则中的【闭】
访问修饰符在一定程度上,保护了线程安全
3.3 常见线程安全类()
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的(不是线程安全的),见后面分析
线程安全类方法的组合
分析下面代码是否线程安全?
Hashtable table = new Hashtable();
// 线程1,线程2
if(table.get("key") == null) {
table.put("key", value);
}
不可变类线程安全性
String、Integer
等都是不可变类(final类),因为其内部的状态不可以改变,因此它们的方法都是线程安全的
有同学或许有疑问,String
有 replace
,substring
等方法可以改变值啊,那么这些方法又是如何保证线程安 全的呢?
创建新的字符串对象
1.subString()
源码
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;//截取长度 = 总长度 - 索引下标
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
//若索引为0?返回本身:创建新的字符串对象
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
2.String
构造器源码
//value为char数组
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
//创建value字符串时,在原有字符串的基础上进行复制,赋值给新字符串(没有改动原有对象属性,直接创建新的)
this.value = Arrays.copyOfRange(value, offset, offset+count);
}
public class Immutable{
private int value = 0;
public Immutable(int value){
this.value = value;
}
public int getValue(){
return this.value;
}
}
如果想增加一个增加的方法呢?
public class Immutable{
private int value = 0;
public Immutable(int value){
this.value = value;
}
public int getValue(){
return this.value;
}
public Immutable add(int v){
return new Immutable(this.value + v);
}
}
四、线程安全实例分析
4.1 一些实例
对一些成员变量和局部变量进行线程安全分析
例1
Servlet运行在tomcat环境下,只有一个实例,可以被多个线程共享使用
public class MyServlet extends HttpServlet {
// 是否安全?否 HashMap不是线程安全的
Map<String,Object> map = new HashMap<>();
// 是否安全?是
String S1 = "...";
// 是否安全?是(final)
final String S2 = "...";
// 是否安全?否(Date类不是)
Date D1 = new Date();
// 是否安全?否(日期对象D2引用值是final,但是new Date()可变,即引用属性是可变的,因此不安全)
final Date D2 = new Date();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
// 使用上述变量
}
}
final修饰的不是值不能变,只是地址不能变(不变的是引用地址,但具体被引用的对象可以变)
例2
public class MyServlet extends HttpServlet {
// 是否安全?否(userService成员变量在Servlet是唯一的,多个线程共享)
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 记录调用次数
private int count = 0; //共享资源
public void update() {
// 临界区
count++;
}
}
例3
Spring AOP
Spring没有加@Scope(...)说明多例,则会默认为单例,即默认被共享,其成员变量默认被共享
@Aspect
@Component
public class MyAspect {
// 是否安全?否(成员变量,默认被共享)
private long start = 0L;
// 前置通知 记录开始时间
@Before("execution(* *(..))")
public void before() {
start = System.nanoTime();
}
// 后置通知 记录结束时间
@After("execution(* *(..))")
public void after() {
long end = System.nanoTime();
System.out.println("cost time:" + (end-start));
}
}
//解决方案:做成环绕通知,将原有成员变量内嵌为局部变量
例4
三层结构的典型调用,典型写法没有问题
/*----------自顶向下分析-----------*/
public class MyServlet extends HttpServlet {
// 是否安全 是(UserService中的成员变量UserDao是私有,而且自身也是安全的)
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全 是(虽然userdao是成员变量,但是内部无成员变量,无状态,与不可变有异曲同工之妙)
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
//无成员变量,因此update()线程安全
public void update() {
String sql = "update user set password = ? where username = ?";
// 是否安全 是(conn是方法内的局部变量)
try (Connection conn = DriverManager.getConnection("","","")){
// ...
} catch (Exception e) {
// ...
}
}
}
例5
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全 否(成员变量,且内部方法不安全)
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全 否(成员变量被共享,对比例4)
private Connection conn = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
Servlet只有一份,导致了UserServiceImpl、UserDaoImpl只有一份,是多线程共享的。Connection是成员变量,也被多个线程所共享
例4、例5分析可知,实际编程中,要避免conn定义为成员变量
如:线程1实例化conn,线程2释放conn,那完犊子了
例6
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
public void update() {
//是否安全 是(每次都创建一个新的局部变量)
//但不推荐这么写
UserDao userDao = new UserDaoImpl();
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
//是否安全 是(引用它的是成员变量形式,实例化就创建一次,因此不存在安全问题,如上)
private Connection = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
例7
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();
}
}
其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法
//子类中 重写foo时 有线程安全问题
public void foo(SimpleDateFormat sdf) {
String dateStr = "1999-10-11 00:00:00";
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
请比较 JDK 中 String 类的实现
如果不设计成final,则继承String类的子类可能会覆盖掉父类的行为,造成线程不安全
final修饰类不能被继承,也没有子类
String类的设计完美体现了Java的闭合原则
4.2 售票实例联系
测试下面代码是否存在线程安全问题,并尝试改正
public class ExerciseSell {
public static void main(String[] args) throws InterruptedException {
// 模拟多人买票 总共1000张票
TicketWindow window = new TicketWindow(1000);
// 所有线程的集合
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 int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
}
改进,在售票函数上加上synchronized:
//售票 临界区:共享变量有读写操作
public synchronized int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
4.3 银行转账实例
public class ExerciseTransfer {
public static void main(String[] args) throws InterruptedException {
//两个账户,初始值为1000
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) {
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
总金额变多了?改进策略还是先去找共享变量,找临界区。可以这样改进吗?
public synchronized void transfer(Account target, int amount) {
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
这样是不行的,因为这是对类加锁,等价于
public void transfer(Account target, int amount) {
synchronized(this) {
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
只是保护了this对象,target对象没有保护,可以在对target加锁,但这样可能会造成死锁,可以写成这样:
public void transfer(Account target, int amount) {
synchronized(Account.class) {
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
this和target共享了Account类,Account类对其所有对象都是共享的。
问题还是存在,银行这一时刻只有一笔交易了。
五、Monitor概念
5.1 Java 对象头
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot虚拟机的对象头包括三部分信息
- Mark Word
- 指向类的指针
- 数组长度
以 32 位虚拟机为例
普通对象
Klass Word:指向对象的类型(一个指针找到它的类对象)
一个int 类型占4个字节,而一个Integer对象占8 + 4个字节
数组对象
其中 Mark Word 结构为
age:垃圾回收时的分代年龄
biased_lock:是否为偏向锁
01/00(biased_lock后一位):加锁状态
Normal:对象正常状态
当对对象进行相应改变,如施加轻量级锁、重量级锁,GC时,相应的Mark Word Structure会发生改变
状态 | 标志位 | 存储内容 |
---|---|---|
未锁定 | 01 | 对象哈希码、对象分代年龄 |
轻量级锁定 | 00 | 指向锁记录的指针 |
膨胀(重量级锁定) | 10 | 执行重量级锁定的指针 |
GC标记 | 11 | 空(不需要记录信息) |
可偏向 | 01 | 偏向线程ID、偏向时间戳、对象分代年龄 |
64 位虚拟机 Mark Word
参考资料:https://stackoverflow.com/questions/26357186/what-is-in-java-object-header
5.2 Monitor概念
Monitor被翻译为监视器或管程(通常称为“锁”)
每个Java对象都可以关联一个Monitor对象(JVM提供),如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针
Monitor结构
Owner:锁的拥有者,唯一性,当线程尝试获得锁时若有其他线程引用,则无法获得
EntryList:阻塞(等待)队列,其他线程无法获得锁时,则一起进入阻塞队列,但是一旦线程释放锁,它们是竞争获得锁(而不是先来后到)
WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
obj对象的MarkWord
结构指向Monitor
对象,当t2执行到synchronized
方法时,首先判断临界区代码是否加锁。
如下图t2首先判断Monitor Owner是否有线程引用,无则获得锁,执行临界区代码,其他线程t1,t3则进入阻塞队列,等待t2释放锁。
- 刚开始
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中等待的线程来竞争锁,竞争的时是非公平的- 图中
WaitSet
中的Thread-0
,Thread-1
是之前获得过锁,但条件不满足进入WAITING状态的线程,后面讲wait-notify
时会分析
- synchronized必须是进入同一个对象的monitor才有上述的效果
- 不加synchronized的对象不会关联监视器,不遵从以上规则
5.3 sychronized原理
代码:
static final object lock = new object();
static int counter = 0;
public static void main(String[] args) {
synchronized (1ock) {
counter++ ;
}
}
反编译成对应字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_ PUBLIC, ACC_ STATIC
Code:
stack=2,1ocals=3, args_ size=1 .
0: getstatic #2 // <- lock引用 (synchronized开始)
3:dup
4: astore_1 // 1ock引用 -> slot 1
5: monitorenter // 将lock对象MarkWord 置为Monitor 指针
6: getstatic #3 // <-i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // ->i
14: aload_1 // <- lock引用
15: monitorexit //将lock对象MarkWord 重置,唤醌EntryList
16: goto 24
//----------------下面是异常处理时,释放锁的字节码-----------------//
19: astore_2 // e->slot2
20: aload_1 // <- lock引用
21: monitorexit // 将lock对象MarkWord 重置,唤醒EntryList
22: aload_2 // <-slot 2 (e)
23: athrow // throw e
24: return
//异常检测
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
六、sychronized进阶原理
6.1 小故事
前言:
- synchronized加锁是关联monitor,monitor是由操作系统提供的,成本昂贵,对程序的性能有影响。
- 从 Java6 开始对synchronized获取锁的方式进行了改进
故事角色
- 老王 - JVM
- 小南 - 线程
- 小女 - 线程
- 房间 - 对象
- 房间门上 - 防盗锁 - Monitor
- 房间门上 - 小南书包 - 轻量级锁
- 房间门上 - 刻上小南大名 - 偏向锁
- 批量重刻名 - 一个类的偏向锁撤销到达 20 阈值
- 不能刻名字 - 批量撤销该类对象的偏向锁,设置该类不可偏向
重量级锁:小南要使用房间保证计算不被其它人干扰(原子性),最初,他用的是防盗锁,当上下文切换时,锁住门。这样,即使他离开了,别人进不了门,他的工作就是安全的。但是,很多情况下没人跟他来竞争房间的使用权。小女是要用房间,但使用的时间上是错开的,小南白天用,小女晚上用。每次上锁太麻烦了,有没有更简单的办法呢?
轻量级锁:小南和小女商量了一下,约定不锁门了,而是谁用房间,谁把自己的书包挂在门口,但他们的书包样式都一样,因此每次进门前得翻翻书包(CAS操作),看课本是谁的,如果是自己的,那么就可以进门,这样省的上锁解锁了。万一书包不是自己的,那么就在门外等,并通知对方下次用锁门的方式。
后来,小女回老家了,很长一段时间都不会用这个房间。小南每次还是挂书包,翻书包,虽然比锁门省事了,但仍然觉得麻烦。
偏向锁:于是,小南干脆在门上刻上了自己的名字:【小南专属房间,其它人勿用】,下次来用房间时,只要名字还在,那么说明没人打扰,还是可以安全地使用房间。如果这期间有其它人要用这个房间,那么由使用者将小南刻的名字擦掉,升级为挂书包的方式。
批量重刻名:同学们都放假回老家了,小南就膨胀了,在 20 个房间刻上了自己的名字,想进哪个进哪个。后来他自己放假回老家了,这时小女回来了(她也要用这些房间),结果就是得一个个地擦掉小南刻的名字,升级为挂书包的方式。老王觉得这成本有点高,提出了一种批量重刻名的方法,他让小女不用挂书包了,可以直接在门上刻上自己的名字
后来,刻名的现象越来越频繁,老王受不了了:算了,这些房间都不能刻名了,只能挂书包 。
6.2 轻量级锁
synchronized默认是使用轻量级锁,轻量级锁发生抢占时会升级为重锁。然后阻塞队列可以通过自旋优化来尽可能减少阻塞
- 轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
- 轻量级锁对使用者是透明的(不可见),即语法仍然是synchronized
假设有两个方法同步块,利用同一个对象加锁
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(Compare and Swap):JDK提供的非阻塞原子性操作,它通过硬件保证了比较——更新操作的原子性。
如果cas
(compare and swap)替换成功,对象头中存储了锁记录地址和状态00
的,表示由该线程给对象加锁,这时图示如下
如果cas失败,有两种情况
-
如果是其它线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程
- 如果是自己执行了synchronized 锁重入(自己又给自己对象加锁了,见下),那么再添加一条Lock Record作为重入的计数
- 见轻量级锁示例代码:t0执行
syn method1(obj)
,获得锁之后继续调用syn method2(obj)
(多出来一个栈帧,见下图),两个加锁的obj
是同一个对象,因此CAS
失败 - 在图中的体现:对象头
lock record 地址 00
在调用method1(obj)
改变了,指向的是第一个栈帧的锁记录,因此第二个栈帧会CAS失败 Lock Record
的null记录锁重入的计数,如上为1,再调用一次++- 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减1
- 当退出synchronized代码块(解锁时) 锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
- 见轻量级锁示例代码:t0执行
- 如果是自己执行了synchronized 锁重入(自己又给自己对象加锁了,见下),那么再添加一条Lock Record作为重入的计数
6.3 锁膨胀
如果在尝试加轻量级锁的过程中,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线程
6.4 自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞
自旋:是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
自旋重试成功的情况
自旋需要cpu资源,所以适合多核cpu
线程1 (cpu1上) | 对象Mark | 线程2 (cpu2上) |
---|---|---|
- | 10 (重量锁) | - |
访问同步块,获取monitor | 10 (重量锁) 重量锁指针 | - |
成功(加锁) | 10 (重量锁) 重量锁指针 | - |
执行同步块 | 10 (重量锁) 重量锁指针 | - |
执行同步块 | 10 (重量锁) 重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10 (重量锁) 重量锁指针 | 自旋重试 |
执行完毕 | 10 (重量锁) 重量锁指针 | 自旋重试 |
成功(解锁) | 无锁 | 自旋重试 |
- | 10 (重量锁) 重量锁指针 | 成功(加锁) |
- | 10 (重量锁) 重量锁指针 | 执行同步块 |
- | ... | ... |
自旋重试失败的情况
线程1 (cpu1上) | 对象Mark | 线程2 (cpu2上) |
---|---|---|
- | 10 (重量锁) | - |
访问同步块,获取monitor | 10 (重量锁) 重量锁指针 | - |
成功(加锁) | 10 (重量锁) 重量锁指针 | - |
执行同步块 | 10 (重量锁) 重量锁指针 | - |
执行同步块 | 10 (重量锁) 重量锁指针 | 访问同步块,获取monitor |
执行同步块 | 10 (重量锁) 重量锁指针 | 自旋重试 |
执行同步块 | 10 (重量锁) 重量锁指针 | 自旋重试 |
执行同步块 | 10 (重量锁) 重量锁指针 | 自旋重试 |
执行同步块 | 10 (重量锁) 重量锁指针 | 阻塞 |
- | ... | ... |
-
在Java 6之后自旋锁是自适应的,比如对象刚刚的- -次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
-
自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
-
Java 7之后不能控制是否开启自旋功能
6.5 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS操作。
Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有
- 升级为轻量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作成功了, 此时该线程就获得了锁对象。( 此时是交替访问临界区, 撤销偏向锁, 升级为轻量级锁)
- 升级为重量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作失败了, 此时说明发生了锁竞争。( 此时是多线程访问临界区, 撤销偏向锁, 升级为重量级锁)
例:
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
}
}
个人理解偏向锁算一种无锁,因为此时没有其他线程来竞争锁,不存在重入,也就不需要考虑线程安全
偏向状态
回忆一下对象头格式
- Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
- Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
- Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后2位为状态(00)
- Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,最后2位为状态(10)
一个对象创建时:
-
如果开启了偏向锁(Biased默认开启),那么对象创建后,
markword
值为0x05
即最后3位为101,这时它的thread、epoch、 age
都为0 -
偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数
-XX:BiasedLockingStartupDelay=0
来禁用延迟 -
如果没有开启偏向锁(Normal),那么对象创建后,
markword
值为0x01
即最后3位为001,这时它的hashcode、age
都为0,第一次用到hashcode
时才会赋值
1)测试延迟特性
2)测试偏向锁
- 利用jol第三方工具来查看对象头信息(注意这里我扩展了jol让它输出更为简洁)
//添如虚拟机参数-XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws IOException {
Dog d = new Dog();
ClassLayout classLayout = ClassLayout.lparseInstance(d);
new Thread(() -> {
log.debug("synchronized前");
System.out.println(classLayout.toPrintableSimple(true));
synchronized (d) {
log.debug("synchronized中");
System.out.println(classlayout.toPrintableSimple(true));
}
log.debug(" synchraoized后");
System.out.println(classLayout.toPrintablesimple(true));
}, "t1").start();
}
输出
11:08:58.117 c. TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
11:08:58.121 C. TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
11:08:58.121 C. TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
处于偏向锁的对象解锁后,线程 id仍存储于对象头中
3)测试禁用
在上面测试代码运行时在添加VM参数 -XX: -UseBiasedLocking
禁用偏向锁
输出
11:13:10.018 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
11:13:10.021 C. TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00100000 00010100 11110011 10001000
11:13:10.021 C. TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
说明:001表示没有加偏向锁,00表示加了轻量级锁
4)测试hashcode
public static void main(String[] args) throws IOException {
Dog d = new Dog();
d.hashcode();//调用对象hashcode,使得偏向锁禁用
ClassLayout classLayout = ClassLayout.lparseInstance(d);
new Thread(() -> {
log.debug("synchronized前");
System.out.println(classLayout.toPrintableSimple(true));
synchronized (d) {
log.debug("synchronized中");
System.out.println(classlayout.toPrintableSimple(true));
}
log.debug(" synchraoized后");
System.out.println(classLayout.toPrintablesimple(true));
}, "t1").start();
}
观察如上的MarkWord格式,Normal下的hashcode占31位,Biased下的thread:54位,装不下hashcode。所以,可偏向对象调了hashcode()后撤销偏向状态
轻量级锁:hashcode会存到线程栈帧的锁记录(lock Record)中
重量级锁:hashcode会存到monitor对象中
撤销偏锁1-调用对象hashCode
调用了对象的hashCode,但偏向锁的对象MarkWord中存储的是线程id,如果调用hashCode会导致偏向锁被撤销
- 轻量级锁会在锁记录中记录hashCode
- 重量级锁会在Monitor中记录hashCode
在调用hashCode后使用偏向锁,记得去掉-XX: -UseBiasedLocking
输出:
11:22:10.386 c.TestBiased [main] - 调用hashCode: 1778535015
11:22:10.391 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 01101010 00000010 01001010 01100111 00000001 正常状态,没有偏向锁
11:22:10.393 C. TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00100000 11000011 11110011 01101000 加了轻量级锁
11:22:10.393 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 01101010 00000010 01001010 01100111 00000001 撤销 正常状态,没有偏向锁
撤销偏锁2-其它线程使用对象
小故事: 线程A门上刻了名字, 但此时线程B也要来使用房间了, 所以要将偏向锁升级为轻量级锁. (线程B要在线程A使用完房间之后
(执行完synchronized代码块)
,再来使用; 否则就成了竞争获取锁对象, 此时就要升级为重量级锁
了)
偏向锁、轻量级锁的使用条件, 都是在于多个线程没有对同一个对象进行锁竞争
的前提下, 如果有锁竞争
,此时就使用重量级锁。
这里演示当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
public class Demo10 {
private static void test2() throws InterruptedException {
Dog d = new Dog();
Thread t1 = new Thread(() -> {
synchronized (d) {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
}
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
//如果不用wait/notify 使用join必须打开下面的注释
//因为: t1线程不能结束,否则底层线程可能被jvm重用作为t2线程,底层线程id是一样的
/*try {
System. in.read(); .
} catch (IOException e) {
e. printStackTrace();
}*/
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(Classlayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(Class Layout.parseInstance(d).toPrintableSimple(true));
}, "t2");
t2.start();
}
}
输出:
[t1] - 0000000 00000000 00000000 0000000 00011111 01000001 00010000 00000101 //偏向锁
[t2] - 00000000 00000000 0000000 0000000 00011111 01000001 00010000 00000101 //偏向锁
[t2] - 00000000 0000000 00000000 0000000 00011111 10110101 11110000 01000000 //撤销偏向锁,改为轻量级锁,保留线程id
[t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001 //恢复正常
撤销偏锁3-调用wait/notify
wait/notify只有重锁才有,任何线程对象调用其时,会升级位重锁
批量重偏向
如果对象虽然被多个线程访问,但没有竞争(上面撤销偏向锁就是这种情况: 一个线程执行完, 另一个线程再来执行, 没有竞争),这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
当撤销偏向锁阈值超过20次后, jvm会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
状态转化:
偏向锁t1 -> t2加入竞争 ->有了竞争,不符合偏向t1了 -> 对于t2,先撤销t1偏锁,再升级轻锁,然后解锁变为不可偏向状态 ->t2连续上步,达到阈值20后 -> jvm默认只有t2了,偏向t2
代码演示
public class Demo10 {
public static void test() {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
synchronized (list) {
list.notify();//唤醒list
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();//阻塞list,释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("===========> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintablesimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintablesimple(true));
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
}, "t2");
t2.start();
}
}
输出
xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 线程id 线程id 线程id 加锁状态
[t1] - 0
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t1] - 1
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t1] - 2
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t1] - 3
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t1] - 4
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t1] - 5
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
...t1 从1到29都是加的线程id(00011111 11101011)偏向锁,状态看最后101
[t2] - ============>
[t2] - 0
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101 //原始t1的偏向锁状态
[t2] - 0
00000000 00000000 00000000 00000000 00100000 01111010 11110110 01110000 //撤销偏向锁,升级轻量级锁
[t2] - 0
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001//解锁后,变为不可偏向状态
[t2] - 1
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t2] - 1
00000000 00000000 00000000 00000000 00100000 01111010 11110110 01110000
[t2] - 1
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
...
//我们发现,到了第20个的时候(从0算第1个),又变成了偏向锁状态,但是偏向的id变成了t2了
//之后所有的对象都是直接偏向的状态,而不是先撤销t1偏锁,再升级轻锁 => 批量重偏向
[t2] - 19
00000000 00000000 00000000 00000000 00011111 11101011 01000000 00000101
[t2] - 19
00000000 00000000 00000000 00000000 00011111 11101011 01010001 00000101
[t2] - 19
00000000 00000000 00000000 00000000 00011111 11101011 01010001 00000101
...
批量撤销
当撤销偏向锁阈值超过40次后,jvm 会这样觉得,自己确实偏向错了, 根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
public class Demo11 {
static Thread t1, t2, t3;
public static void test() {
Vector<Dog> list = new Vector<>();
int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
//39个对象加上偏向锁,偏向t1线程
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
//39个对象加完锁唤醒t2(park,unpark方式)
LockSupport.unpark(t2);
}
}, "t1");
t1.start();
t2 = new Thread(() -> {
LockSupport.park();//先阻塞自己
log.debug("============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);//拿出list对象
Log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
//对象加上偏向锁,偏向t2线程
//前19个对象是撤销t1偏向锁,之后对象是批量重偏向
synchronized (d) {
Log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintablesimple(true));
}
Log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
//此时已经重偏向了20次
LockSupport.unpark(t3);//唤醒t3
}, "t2");
t2.start();
t3 = new Thread(() -> {
LockSupport.park();//先阻塞自己
log.debug("============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);//拿出list对象
Log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
//对象加上偏向锁,偏向t3线程
//前19个对象是撤销t2偏向锁,注意:之后对象也是撤销t2偏锁,没那么多机会重偏向锁了
synchronized (d) {
Log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintablesimple(true));
}
Log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
//最后撤销偏向锁达到39次
}, "t3");
t3.start();
t3.join();
/*
当撤销偏向锁阈值超过40次后,jvm会这样觉得,自己确实偏向错了,根本就不该偏向。
于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的,所以new Dog()是不可偏向的
*/
Log.debug(ClassLayout.parseInstance(new Dog()).toPrintableSimple(true));
}
}
批量重偏向与撤销是针对类的优化与对象无关
参考资料
https://github.com/farmerjohngit/myblog/issues/12
https://www.cnblogs.com/LemonFive/p/11246086.html
https://www.cnblogs.com/LemonFive/p/11248248.html
偏向锁论文
6.6 锁消除
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
- 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
- 如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
例如下面的智障代码,根本起不到锁的作用
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f( )方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
案例:
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class Demo12 {
static int x = 0;
@Benchmark
public void a() throws Exception {
x++;
}
@Benchmark
//JIT 即时编译器
//对热点代码(如循环),超过一定阈值,对代码进行优化
public void b () throws Exception {
object o = new object();//o对象是b()的局部变量,没有竞争
//加锁和不加锁都一样,所以实际执行时JIT就把锁消除了
synchronized (o) {
x++;
}
}
}
java -jar benchmarks.jar
(打包执行)
Benchmark Mode Samples Score Score error Units
c.i. MyBenchmark. a avgt 5 1.542 0.056 ns/op
c.i. MyBenchmark. b avgt 5 1.518 0.091 ns/op
score值,方法执行时间,越小性能越高,可以看出差不多的
java -XX:-EliminateLocks -jar benchmarks.jar
关闭锁消除
Benchmark Mode Samples Score Score error Units
c.i. MyBenchmark. a avgt 5 1.542 0.018 ns/op
c.i. MyBenchmark. b avgt 5 16.976 1.572 ns/op
不去做锁消除,可以看出性能差异了。
JVM会做逃逸分析,发现Object o,是局部变量,不会逃出b()方法的作用范围,自然不会被共享,那么锁也不会被其他方法拿到,所以直接消除了锁。
6.7 锁粗化
对相同对象多次加锁,导致线程发生多次重入,可以使用锁粗化方式来优化,这不同于之前讲的细分锁的粒度。
6.8 总结
- synchronized锁原来只有重量级锁,依赖操作系统的mutex指令,需要用户态和内核态切换,性能损耗十分明显
- 重量级锁 要用到monitor对象,而偏向锁则在Mark Word记录线程ID进行对比,重量级锁则是拷贝Mark Word到Lock Record,用CAS +自旋的方式获取。
- 引用了偏向锁和轻量级锁,就是为了在不同的使用场景使用不同的锁,进而提高效率。
- 锁只有升级,没有降级
- 只有一个线程进入临界区,偏向锁
- 多个线程交替进入临界区,轻量级锁
- 多个线程同时进入临界区,重量级锁
七、 wait和notify
1.1 小故事
接着前面6.1的小故事
-
由于条件不满足,小南不能继续进行计算
-
但小南如果一直占用着锁,其它人就得一直阻塞,效率太低
-
于是老王单开了一间休息室(调用 wait 方法),让小南到休息室(WaitSet)等着去了,但这时锁释放开,其它人可以由老王随机安排进屋,直到小M将烟送来,大叫一声 [ 你的烟到了 ] (调用 notify 方法)
-
小南于是可以离开休息室,重新进入竞争锁的队列
老王是object,其他人都是线程
之前 Monitor结构中的WaitSet和EntryList队列看了这个小故事,应该就明白了。
1.2 wait、notify介绍 (必须要获取到锁对象, 才能调用这些方法)
当线程0获得到了锁, 成为Monitor的Owner, 但是此时它发现自己想要执行synchroized代码块的条件不满足; 此时它就调用obj.wait方法, 进入到Monitor中的WaitSet集合, 此时线程0的状态就变为WAITING
处于BLOCKED和WAITING状态的线程都为阻塞状态,CPU都不会分给他们时间片。但是有所区别:
- BLOCKED状态的线程是在竞争锁对象时,发现Monitor的Owner已经是别的线程了,此时就会进入EntryList中,并处于BLOCKED状态
- WAITING状态的线程是获得了对象的锁,但是自身的原因无法执行synchroized的临界区资源需要进入阻塞状态时,锁对象调用了wait方法而进入了WaitSet中,处于WAITING状态
处于BLOCKED状态的线程会在锁被释放的时候被唤醒
处于WAITING状态的线程只有被锁对象调用了notify方法(obj.notify/obj.notifyAll),才会被唤醒。然后它会进入到EntryList, 重新竞争锁 (此时就将锁升级为重量级锁)
类别操作系统的阻塞态和就绪态?
1.3 API介绍
下面的三个方法都是Object中的方法; 通过锁对象来调用
- wait(): 让获得对象锁的线程到waitSet中一直等待
- wait(long n) : 当该等待线程没有被notify, 等待时间到了之后, 也会自动唤醒
- notify(): 让获得对象锁的线程, 使用锁对象调用notify去waitSet的等待线程中挑一个唤醒
- notifyAll() : 让获得对象锁的线程, 使用锁对象调用notifyAll去唤醒waitSet中所有的等待线程
它们都是线程之间进行协作的手段, 都属于Object对象的方法, 必须获得此对象的锁, 才能调用这些方法
注:只有当对象被锁以后(成为Owner),才能调用wait和notify方法
public class Test1 {
final static Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
//只有在对象被锁住后才能调用wait方法
synchronized (LOCK) {
LOCK.wait();
}
}
}
演示wait和notify
方法
@Slf4j(topic = "c.TestWaitNotify")
public class TestWaitNotify {
final static Object obj = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
// 只有获得锁对象之后, 才能调用wait/notify
obj.wait(); // 此时t1线程进入WaitSet等待
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码....");
}
},"t1").start();
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait(); // 此时t2线程进入WaitSet等待
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码....");
}
},"t2").start();
// 让主线程等两秒在执行,为了`唤醒`,不睡的话,那两个线程还没进入waitSet,主线程就开始唤醒了
sleep(0.5);
log.debug("唤醒 obj 上其它线程");
// 只有获得锁对象之后, 才能调用wait/notify
synchronized (obj) {
// obj.notify(); // 唤醒waitset中的一个线程
obj.notifyAll(); // 唤醒waitset中的全部等待线程
}
}
}
1.4 Sleep(long n) 和 Wait(long n)的区别
不同点
- Sleep是Thread类的静态方法,Wait是Object的方法,Object又是所有类的父类,所以所有类都有Wait方法。
- Sleep在阻塞的时候不会释放锁,而Wait在阻塞的时候会释放锁 (不释放锁的话, 其他线程就无法唤醒该线程了)
- Sleep方法不需要与synchronized一起使用,而Wait方法需要与synchronized一起使用(wait/notify等方法, 必须要使用对象锁来调用)
相同点
- 阻塞状态都为TIMED_WAITING (限时等待)
sleep方法 / wait方法 测试
@Slf4j(topic = "c.SleepTest")
public class SleepTest {
public static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
log.debug("获得锁了");
try {
// Thread.sleep(5000); // 主线程需要等5s才能获得到锁.所以所在sleep期间, 是不会释放锁的
lock.wait(5000); // 调用wait方法会立刻释放锁, 不然主线程就拿不到lock锁了, 当等待5s后程序才结束
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
// 主线程睡一秒
Sleeper.sleep(1);
synchronized (lock) {
log.debug("获得锁了");
}
}
}
在sleep期间, 锁是不会被释放的
当调用wait方法后, 锁就会被立刻释放
1.5 wait/notify的正确使用
下面将采用逐步向下优化的方式理解wait/notify的正确用法
Step 1
public class WaitNotifyTest {
static final Object room = new Object();
static boolean hasCigarette = false;
static boolean hasTakeout = false;
public static void main(String[] args) {
//思考下面的解决方案好不好,为什么?
new Thread(() -> {
synchronized (room) {
log.debug("有烟没?[{}]", hasCigarette);
if (!hasCigarette) {
log.debug("没烟,先歇会!");
Sleeper.sleep(2); // 会阻塞2s, 不会释放锁
}
log.debug("有烟没?[{}]", hasCigarette);
if (hasCigarette) {
log.debug("可以开始干活了");
}
}
}, "小南").start();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
synchronized (room) {
log.debug("可以开始干活了");
}
}, "其它人").start();
}
Sleeper.sleep(1);
new Thread(() -> {
// 此时没有加锁, 所以会优先于其他人先执行
// 这里能不能加 synchronized (room)?
//synchronized (room) {
// 如果加锁的话, 送烟人也需要等待小南睡2s的时间,此时即使送到了,小南线程也将锁释放了..
hasCigarette = true;
log.debug("烟到了噢!");
//}
}, "送烟的").start();
}
}
不给送烟线程加synchronized
输出情况
给送烟线程加synchronized
输出情况
小南没走,烟送不到;小南走了,烟就送到了,但是人还没干活就走了
- 其它干活的线程,都要一直阻塞,效率太低
- 小南线程必须睡足 2s 后才能醒来,就算烟提前送到,也无法立刻醒来
- 加了 synchronized (room) 后,就好比小南在里面反锁了门睡觉,烟根本没法送进门,main 没加synchronized 就好像 main 线程是翻窗户进来的
- 解决方法,使用 wait - notify 机制
Step2
public class WaitNotifyTest {
static final Object room = new Object();
static boolean hasCigarette = false;
static boolean hasTakeout = false;
public static void main(String[] args) {
new Thread(() -> {
synchronized (room) {
log.debug("有烟没?[{}]", hasCigarette);
if (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
room.wait(); // 此时进入到waitset等待集合, 同时会释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("有烟没?[{}]", hasCigarette);
if (hasCigarette) {
log.debug("可以开始干活了");
}
}
}, "小南").start();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
// 小南进入等待状态了, 其他线程就可以获得锁了
synchronized (room) {
log.debug("可以开始干活了");
}
}, "其它人").start();
}
Sleeper.sleep(1);
new Thread(() -> {
synchronized (room) {
hasCigarette = true;
log.debug("烟到了噢!");
room.notify();
}
}, "送烟的").start();
}
}
该方法:
- 解决了其它干活的线程阻塞的问题
- 但如果有其它线程也在等待条件呢
但如果此时除了小南在等待唤醒, 还有一个线程也在等待唤醒呢? 此时的notify
方法会唤醒谁呢?看下面这个例子
Step3
public class WaitNotifyTest {
static final Object room = new Object();
static boolean hasCigarette = false;
static boolean hasTakeout = false;
public static void main(String[] args) {
new Thread(() -> {
synchronized (room) {
log.debug("有烟没?[{}]", hasCigarette);
if (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
room.wait(); // 此时进入到waitset等待集合, 同时会释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("有烟没?[{}]", hasCigarette);
if (hasCigarette) {
log.debug("可以开始干活了");
}else {
log.debug("没干成活...");
}
}
}, "小南").start();
new Thread(() -> {
synchronized (room) {
log.debug("外卖送到没?[{}]", hasTakeout);
if (!hasTakeout) {
log.debug("没外卖,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("外卖送到没?[{}]", hasTakeout);
if (hasTakeout) {
log.debug("可以开始干活了");
} else {
log.debug("没干成活...");
}
}
}, "小女").start();
Sleeper.sleep(1);
new Thread(() -> {
synchronized (room) {
hasTakeout = true;
log.debug("外卖到了噢!");
room.notify();
}
}, "送外卖的").start();
}
}
- notify 只能随机唤醒一个 WaitSet 中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程,称之为【虚假唤醒】
- 解决方法,改为 notifyAll
Step4
new Thread(() -> {
synchronized (room) {
hasTakeout = true;
log.debug("外卖到了噢!");
room.notifyAll();
}
}, "送外卖的").start();
还是唤醒了小南, 小南还是回去看看送来的是外卖还是烟. 很麻烦, 怎么解决?
- 用 notifyAll 仅解决某个线程的唤醒问题,但使用 if + wait 判断仅有一次机会,一旦条件不成立,就没有重新判断的机会了
- 解决方法,用 while + wait,当条件不成立,再次 wait
Step5
- 使用
while循环
来解决虚假唤醒
public class Main {
static final Object room = new Object();
static boolean hasCigarette = false;
static boolean hasTakeout = false;
public static void main(String[] args) {
new Thread(() -> {
synchronized (room) {
log.debug("有烟没?[{}]", hasCigarette);
while (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
room.wait(); // 此时进入到waitset等待集合, 同时会释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("有烟没?[{}]", hasCigarette);
if (hasCigarette) {
log.debug("可以开始干活了");
}
}
}, "小南").start();
new Thread(() -> {
synchronized (room) {
log.debug("外卖送到没?[{}]", hasTakeout);
while (!hasTakeout) {
log.debug("没外卖,先歇会!");
try {
room.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("外卖送到没?[{}]", hasTakeout);
if (hasTakeout) {
log.debug("可以开始干活了");
} else {
log.debug("没干成活...");
}
}
}, "小女").start();
Sleeper.sleep(1);
new Thread(() -> {
synchronized (room) {
hasTakeout = true;
log.debug("外卖到了噢!");
room.notifyAll();
}
}, "送外卖的").start();
}
}
因为改为while
如果唤醒之后, 就在while循环中执行了, 不会跑到while外面去执行"有烟没…", 此时小南就不需要每次notify, 就去看是不是送来的烟, 如果是烟, while就为false了。
正确姿势
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}
//另一个线程
synchronized(lock) {
lock.notifyAll();
}
八、 park & unpack (重要)
8.1 基本使用
暂停当前线程 | 恢复某个线程的运行 |
---|---|
LockSupport.park(); | LockSupport.unpark(暂停线程对象); |
- park/unpark都是LockSupport类中的的方法
- 先调用unpark后,再调用park, 此时park不会暂停线程
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(thread);
8.2 park、unpark 原理
每个线程都有自己的一个 Parker 对象,由三部分组成 _counter, _cond和 _mutex
打个比喻线程就像一个旅人,Parker 就像他随身携带的背包,条件变量 _ cond就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
调用 park 就是要看需不需要停下来歇息
- 如果备用干粮耗尽,那么钻进帐篷歇息
- 如果备用干粮充足,那么不需停留,继续前进
调用 unpark,就好比干粮充足
- 如果这时线程还在帐篷,就唤醒让他继续前进
- 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
- 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
先调用park再调用upark的过程
先调用park的情况
- 当前线程调用 Unsafe.park() 方法
- 检查 _counter, 本情况为0, 这时, 获得_mutex 互斥锁(mutex对象有个等待队列 _cond)
- 线程进入 _cond 条件变量阻塞
- 设置_counter = 0 (没干粮了)
调用unpark
- 调用Unsafe.unpark(Thread_0)方法,设置_counter 为 1
- 唤醒 _cond 条件变量中的 Thread_0
- Thread_0 恢复运行
- 设置 _counter 为 0
先调用upark再调用park的过程
- 调用
Unsafe.unpark(Thread_0)
方法,设置_counter 为 1
- 当前
线程
调用Unsafe.park()
方法 - 检查
_counter
,本情况为1
,这时线程无需阻塞,继续运行
- 设置 _counter 为 0
九、重新理解线程状态转换
假设有线程 Thread t
9.1 NEW –> RUNNABLE
t.start()方法时, NEW --> RUNNABLE
9.2 RUNNABLE <–> WAITING
情况1
线程用synchronized(obj)获取了对象锁后
调用 obj.wait()方法时,t 线程进入waitSet中, 从RUNNABLE --> WAITING
调用 obj.notify(),obj.notifyAll(),t.interrupt() 时, 唤醒的线程都到entrySet阻塞队列成为BLOCKED状态, 在阻塞队列,和其他线程再进行 竞争锁
- 竞争锁成功,t 线程从 WAITING --> RUNNABLE
- 竞争锁失败,t 线程从 WAITING --> BLOCKED
情况2
- 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING ,注意是当前线程在t线程对象在waitSet上等待
- t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
情况3
- 当前线程调用 LockSupport.park() 方法会让当前线程从RUNNABLE --> WAITING
- 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE
9.3 RUNNABLE <–> TIMED_WAITING
情况1(带超时时间的wait)
t 线程用synchronized(obj) 获取了对象锁后,调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
t 线程等待时间超过了 n 毫秒,或调用 obj.notify() , obj.notifyAll() , t.interrupt() 时; 唤醒的线程都到entrySet阻塞队列成为BLOCKED状态, 在阻塞队列,和其他线程再进行 竞争锁
- 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
- 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
情况2
当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING 注意是当前线程在t 线程对象的waitSet等待
当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING --> RUNNABLE
情况3
当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
当前线程等待时间超过了 n 毫秒或调用了线程的 interrupt() ,当前线程从 TIMED_WAITING --> RUNNABLE
情况4
当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
调用LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE\
9.4 RUNNABLE <–> BLOCKED
- t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE –> BLOCKED
- 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争 成功,从 BLOCKED –> RUNNABLE ,其它失败的线程仍然 BLOCKED
9.5 RUNNABLE <–> TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED
十、多把锁
10.1 多把不相干的锁
小故事
- 一间大屋子有两个功能:
睡觉、学习,互不相干
。 - 现在小南要学习,小女要睡觉,但如果只用一间屋子(一个对象锁)的话,那么
并发度很低。
小南获得锁之后, 学完习之后, 小女才能进来睡觉。
@Slf4j(topic = "c.BigRoom")
public class BigRoomTest {
public static void main(String[] args) {
BigRoom bigRoom = new BigRoom();
new Thread(() -> bigRoom.sleep(), "小南").start();
new Thread(() -> bigRoom.study(), "小女").start();
}
}
@Slf4j(topic = "c.BigRoom")
class BigRoom {
public void sleep() {
synchronized (this) {
log.debug("sleeping 2 小时");
Sleeper.sleep(2);
}
}
public void study() {
synchronized (this) {
log.debug("study 1 小时");
Sleeper.sleep(1);
}
}
}
改进方法是准备多个房间(多个对象锁)
小南, 小女
获取不同的锁即可
@Slf4j(topic = "c.BigRoom")
class BigRoom {
private final Object studyRoom = new Object();
private final Object bedRoom = new Object();
public void sleep() {
synchronized (bedRoom) {
log.debug("sleeping 2 小时");
Sleeper.sleep(2);
}
}
public void study() {
synchronized (studyRoom) {
log.debug("study 1 小时");
Sleeper.sleep(1);
}
}
}
将锁的粒度细分
- 好处,是可以
增强并发度
- 坏处,如果一个线程需要同时获得多把锁,就
容易发生死锁
十一、 活跃性
因为某种原因,使得代码一直无法执行完毕,这样的现象叫做 活跃性
活跃性相关的一系列问题都可以用 ReentrantLock 进行解决。
11.1、死锁 (重点)
有这样的情况:一个线程需要 同时获取多把锁,这时就容易发生死锁
如:线程1获取A对象锁, 线程2获取B对象锁; 此时线程1又想获取B对象锁, 线程2又想获取A对象锁; 它们都等着对象释放锁, 此时就称为死锁
public static void main(String[] args) {
final Object A = new Object();
final Object B = new Object();
new Thread(()->{
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
}
}
}).start();
new Thread(()->{
synchronized (B) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (A) {
}
}
}).start();
}
发生死锁的必要条件 (重点)
互斥条件
在一段时间内,一种资源只能被一个进程所使用
请求和保持条件
进程已经拥有了至少一种资源,同时又去申请其他资源。因为其他资源被别的进程所使用,该进程进入阻塞状态,并且不释放自己已有的资源
不可抢占条件
进程对已获得的资源在未使用完成前不能被强占,只能在进程使用完后自己释放
循环等待条件
发生死锁时,必然存在一个进程——资源的循环链。
11.2 定位死锁的方法
方式一、JPS + JStack 进程ID
- jps先找到JVM进程
- jstack 进程ID
在Java控制台中的Terminal中输入 jps 指令可以查看正在运行中的进程ID,使用 jstack 进程ID 可以查看进程状态。
方式二、 jconsole检测死锁
打开jconsole,连接到死锁程序的线程
11.3 死锁举例 - 哲学家就餐问题 (重点)
有五位哲学家,围坐在圆桌旁。
他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。
吃饭时要用两根筷子吃,桌上共有 5 根筷子,每位哲学家左右手边各有一根筷子。
如果筷子被身边的人拿着,自己就得等待
当每个哲学家即线程持有一根筷子时,他们都在等待另一个线程释放锁,因此造成了死锁。
public class TestDeadLock {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
//哲学家类
@Slf4j(topic = "c.Philosopher")
class Philosopher extends Thread {
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
// 尝试获得左手筷子
synchronized (left) {
// 尝试获得右手筷子
synchronized (right) {
eat();
}
}
}
}
Random random = new Random();
private void eat() {
log.debug("eating...");
Sleeper.sleep(0.5);
}
}
//筷子类
class Chopstick {
String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "筷子{" + name + '}';
}
}
通过jps, jstack 进程id
查看死锁原因
Found one Java-level deadlock:
发现了一个Java级别的死锁
Found one Java-level deadlock:
=============================
"阿基米德":
waiting to lock monitor 0x000000001fd941a8 (object 0x000000076b735028, a cn.itcast.n4.deadlock.v1.Chopstick),
which is held by "苏格拉底"
"苏格拉底":
waiting to lock monitor 0x000000001ccd33c8 (object 0x000000076b735068, a cn.itcast.n4.deadlock.v1.Chopstick),
which is held by "柏拉图"
"柏拉图":
waiting to lock monitor 0x000000001ccd3318 (object 0x000000076b7350a8, a cn.itcast.n4.deadlock.v1.Chopstick),
which is held by "亚里士多德"
"亚里士多德":
waiting to lock monitor 0x000000001ccd0a88 (object 0x000000076b7350e8, a cn.itcast.n4.deadlock.v1.Chopstick),
which is held by "赫拉克利特"
"赫拉克利特":
waiting to lock monitor 0x000000001ccd0b38 (object 0x000000076b735128, a cn.itcast.n4.deadlock.v1.Chopstick),
which is held by "阿基米德"
Java stack information for the threads listed above:
===================================================
"阿基米德":
at cn.itcast.n4.deadlock.v1.Philosopher.run(TestDeadLock.java:41)
- waiting to lock <0x000000076b735028> (a cn.itcast.n4.deadlock.v1.Chopstick)
- locked <0x000000076b735128> (a cn.itcast.n4.deadlock.v1.Chopstick)
"苏格拉底":
at cn.itcast.n4.deadlock.v1.Philosopher.run(TestDeadLock.java:41)
- waiting to lock <0x000000076b735068> (a cn.itcast.n4.deadlock.v1.Chopstick)
- locked <0x000000076b735028> (a cn.itcast.n4.deadlock.v1.Chopstick)
"柏拉图":
at cn.itcast.n4.deadlock.v1.Philosopher.run(TestDeadLock.java:41)
- waiting to lock <0x000000076b7350a8> (a cn.itcast.n4.deadlock.v1.Chopstick)
- locked <0x000000076b735068> (a cn.itcast.n4.deadlock.v1.Chopstick)
"亚里士多德":
at cn.itcast.n4.deadlock.v1.Philosopher.run(TestDeadLock.java:41)
- waiting to lock <0x000000076b7350e8> (a cn.itcast.n4.deadlock.v1.Chopstick)
- locked <0x000000076b7350a8> (a cn.itcast.n4.deadlock.v1.Chopstick)
"赫???克利特":
at cn.itcast.n4.deadlock.v1.Philosopher.run(TestDeadLock.java:41)
- waiting to lock <0x000000076b735128> (a cn.itcast.n4.deadlock.v1.Chopstick)
- locked <0x000000076b7350e8> (a cn.itcast.n4.deadlock.v1.Chopstick)
Found 1 deadlock.
避免死锁的方法
- 在线程使用锁对象时, 采用固定加锁的顺序, 可以使用Hash值的大小来确定加锁的先后
- 尽可能缩减加锁的范围, 等到操作共享变量的时候才加锁
- 使用可释放的定时锁 (一段时间申请不到锁的权限了, 直接释放掉)
11.4 活锁
活锁
出现在两个线程 互相改变对方的结束条件
,谁也无法结束。
@Slf4j(topic = "c.TestLiveLock")
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}
避免活锁的方法
在线程执行时,中途给予 不同的间隔时间, 让某个线程先结束即可。
死锁与活锁的区别
- 死锁是因为线程互相持有对象想要的锁,并且都不释放,最后到时线程阻塞,停止运行的现象。
- 活锁是因为线程间修改了对方的结束条件,而导致代码一直在运行,却一直运行不完的现象。
11.5 饥饿
- 某些线程因为优先级太低,导致一直无法获得资源的现象。
- 在使用
顺序加锁
时,可能会出现饥饿现象
十二、 ReentrantLock (重点)
相对于synchronized,ReentrantLock 所具备的特点
支持锁重入
可重入锁是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此 有权利再次获取这把锁
可中断
lock.lockInterruptibly() : 可以被其他线程打断的中断锁
可以设置超时时间
lock.tryLock(时间) : 尝试获取锁对象, 如果超过了设置的时间, 还没有获取到锁, 此时就退出阻塞队列, 并释放掉自己拥有的锁
可以设置为公平锁
(先到先得) 默认是非公平, true为公平 new ReentrantLock(true)
支持多个条件变量( 有多个waitset)
(可避免虚假唤醒) - lock.newCondition()创建条件变量对象; 通过条件变量对象调用 await/signal方法, 等待/唤醒
synchronized是关键字级别的加锁,ReentrantLock则是对象级别的,基本语法如下:
//获取ReentrantLock对象
private ReentrantLock lock = new ReentrantLock();
//加锁
lock.lock();
try {
//需要执行的代码
}finally {
//释放锁
lock.unlock();
}
3.1、支持锁重入
- 可重入锁是指
同一个线程如果首次获得了这把锁
,那么因为它是这把锁的拥有者
,因此 有权利再次获取这把锁 - 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
@Slf4j(topic = "c.TestReentrant")
public class TestReentrant {
static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
method1();
}
public static void method1() {
lock.lock();
try {
log.debug("execute method1");
method2();
} finally {
lock.unlock();
}
}
public static void method2() {
lock.lock();
try {
log.debug("execute method2");
method3();
} finally {
lock.unlock();
}
}
public static void method3() {
lock.lock();
try {
log.debug("execute method3");
} finally {
lock.unlock();
}
}
}
可以看到执行结果, 当前线程在执行时多次获取锁, 并不会被锁挡住, 而是正常运行
3.2、可中断
(针对于lockInterruptibly()方法获得的中断锁) 直接退出阻塞队列, 获取锁失败
synchronized 和 reentrantlock.lock() 的锁, 是不可被打断的; 也就是说别的线程已经获得了锁, 我的线程就需要一直等待下去. 不能中断 可被中断的锁, 通过lock.lockInterruptibly()获取的锁对象, 可以通过调用阻塞线程的interrupt()方法
如果某个线程处于阻塞状态,可以调用其interrupt方法让其停止阻塞,获得锁失败
处于阻塞状态的线程,被打断了就不用阻塞了,直接停止运行
可中断的锁, 在一定程度上可以被动的减少死锁的概率, 之所以被动, 是因为我们需要手动调用阻塞线程的interrupt方法;
测试使用lock.lockInterruptibly()可以从阻塞队列中,打断
private static void test1() {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
log.debug("启动...");
try {
// 尝试获取锁, 并且这个锁是可以被打断的 如果被打断就会抛出异常
// 如果有竞争就进入阻塞队列
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("等锁的过程中被打断");
return;
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
//主线程上锁
lock.lock();
log.debug("获得了锁");
t1.start();
try {
sleep(1);
t1.interrupt();
log.debug("执行打断");
} finally {
lock.unlock();
}
}
3.3、锁超时
(lock.tryLock())
直接退出阻塞队列, 获取锁失败
防止无限制等待, 减少死锁
- 使用 lock.tryLock() 方法会返回获取锁是否成功。如果成功则返回true,反之则返回false。
- 并且tryLock方法可以设置指定等待时间,参数为:tryLock(long timeout, TimeUnit unit) , 其中timeout为最长等待时间,TimeUnit为时间单位
获取锁的过程中, 如果超过等待时间, 或者被打断, 就直接从阻塞队列移除, 此时获取锁就失败了, 不会一直阻塞着 ! (可以用来实现死锁问题)
不设置等待时间, 立即失败
@Slf4j(topic = "c.ReentrantTest")
public class ReentrantTest {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获得锁");
// 此时肯定获取失败, 因为主线程已经获得了锁对象
if (!lock.tryLock()) {
log.debug("获取立刻失败,返回");
return;
}
try {
log.debug("获得到锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock();
log.debug("获得到锁");
t1.start();
// 主线程2s之后才释放锁
sleep(2);
log.debug("释放了锁");
lock.unlock();
}
}
设置等待时间, 超过等待时间还没有获得锁, 失败, 从阻塞队列移除该线程
@Slf4j(topic = "c.ReentrantTest")
public class ReentrantTest {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获得锁");
try {
// 设置等待时间, 超过等待时间 / 被打断, 都会获取锁失败; 退出阻塞队列
if (!lock.tryLock(1, TimeUnit.SECONDS)) {
log.debug("获取锁超时,返回");
return;
}
} catch (InterruptedException e) {
log.debug("被打断了, 获取锁失败, 返回");
e.printStackTrace();
return;
}
try {
log.debug("获得到锁");
} finally {
lock.unlock();
}
}, "t1");
lock.lock();
log.debug("获得到锁");
t1.start();
// t1.interrupt();
// 主线程2s之后才释放锁
sleep(2);
log.debug("main线程释放了锁");
lock.unlock();
}
}
超时的打印
中断的打印
通过lock.tryLock()
来解决, 哲学家就餐
问题 (重点
)
lock.tryLock(时间)
: 尝试获取锁对象, 如果超过了设置的时间, 还没有获取到锁, 此时就退出阻塞队列, 并释放掉自己拥有的锁
@Override
public void run() {
while (true) {
// 获得了left左手边筷子 (针对五个哲学家, 它们刚开始肯定都可获得左筷子)
if (left.tryLock()) {
try {
// 此时发现它的right筷子被占用了, 使用tryLock(),
// 尝试获取失败, 此时它就会将自己左筷子也释放掉
// 临界区代码
if (right.tryLock()) {//尝试获取右手边筷子, 如果获取失败, 则会释放左边的筷子
try {
eat();
} finally {
right.unlock();
}
}
} finally {
left.unlock();
}
}
}
}
3.4、公平锁 new ReentrantLock(true)
ReentrantLock默认是不公平锁, 可以指定为公平锁。
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
在线程获取锁失败,进入阻塞队列时,先进入的会在锁被释放后先获得锁。这样的获取方式就是公平的。一般不设置ReentrantLock为公平的, 没必要,会降低并发度
Synchronized底层的Monitor锁就是不公平的, 和谁先进入阻塞队列是没有关系的。
//默认是不公平锁,需要在创建时指定为公平锁
ReentrantLock lock = new ReentrantLock(true);
公平锁与非公平锁
公平锁 (new ReentrantLock(true))
- 公平锁, 可以把竞争的线程放在一个先进先出的阻塞队列上
- 只要持有锁的线程执行完了, 唤醒阻塞队列中的下一个线程获取锁即可; 此时先进入阻塞队列的线程先获取到锁
非公平锁 (synchronized, new ReentrantLock())
- 非公平锁, 当阻塞队列中已经有等待的线程A了, 此时后到的线程B, 先去尝试看能否获得到锁对象. 如果获取成功, 此时就不需要进入阻塞队列了. 这样以来后来的线程B就先活的到锁了
所以公平和非公平的区别 : 线程执行同步代码块时, 是否回去尝试获取锁, 如果会尝试获取锁, 那就是非公平的, 如果不会尝试获取锁, 直接进入阻塞队列, 再等待被唤醒, 那就是公平的
如果不进如队列呢? 线程一直尝试获取锁不就行了?
一直尝试获取锁, 在synchronized轻量级锁升级为重量级锁时, 做的一个优化, 叫做自旋锁, 一般很消耗资源, cpu一直空转, 最后获取锁也失败, 所以不推荐使用。在jdk6对于自旋锁有一个机制, 在重试获得锁指定次数就失败等等
3.5、条件变量
(可避免虚假唤醒) - lock.newCondition()创建条件变量对象; 通过条件变量对象调用await/signal
方法, 等待/唤醒
- Synchronized 中也有条件变量,就是Monitor监视器中的 waitSet等待集合,当条件不满足时进入waitSet 等待
- ReentrantLock 的条件变量比 synchronized 强大之处在于,它是 支持多个条件变量。
- 这就好比synchronized 是那些不满足条件的线程都在一间休息室等通知; (此时会造成虚假唤醒), 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒; (可以避免虚假唤醒)
使用要点:
- await 前需要 获得锁
- await 执行后,会释放锁,进入 conditionObject (条件变量)中等待
- await 的线程被唤醒(或打断、或超时)取重新竞争 lock ;竞争 lock 锁成功后,从 await 后继续执行
- signal 方法用来唤醒条件变量(等待室)汇总的某一个等待的线程
- signalAll方法, 唤醒条件变量(休息室)中的所有线程
ReentrantLock可以设置多个条件变量(多个休息室), 相对于synchronized底层monitor锁中waitSet
@Slf4j(topic = "c.ConditionVariable")
public class ConditionVariable {
private static boolean hasCigarette = false;
private static boolean hasTakeout = false;
private static final ReentrantLock lock = new ReentrantLock();
// 等待烟的休息室
static Condition waitCigaretteSet = lock.newCondition();
// 等外卖的休息室
static Condition waitTakeoutSet = lock.newCondition();
public static void main(String[] args) {
new Thread(() -> {
lock.lock();
try {
log.debug("有烟没?[{}]", hasCigarette);
while (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
// 此时小南进入到 等烟的休息室
waitCigaretteSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("烟来咯, 可以开始干活了");
} finally {
lock.unlock();
}
}, "小南").start();
new Thread(() -> {
lock.lock();
try {
log.debug("外卖送到没?[{}]", hasTakeout);
while (!hasTakeout) {
log.debug("没外卖,先歇会!");
try {
// 此时小女进入到 等外卖的休息室
waitTakeoutSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("外卖来咯, 可以开始干活了");
} finally {
lock.unlock();
}
}, "小女").start();
sleep(1);
new Thread(() -> {
lock.lock();
try {
log.debug("送外卖的来咯~");
hasTakeout = true;
// 唤醒等外卖的小女线程
waitTakeoutSet.signal();
} finally {
lock.unlock();
}
}, "送外卖的").start();
sleep(1);
new Thread(() -> {
lock.lock();
try {
log.debug("送烟的来咯~");
hasCigarette = true;
// 唤醒等烟的小南线程
waitCigaretteSet.signal();
} finally {
lock.unlock();
}
}, "送烟的").start();
}
}
十三章、本章小结
本章我们需要重点掌握的是
- 分析多线程访问共享资源时,哪些代码片段属于临界区
- 使用 synchronized 互斥解决临界区的线程安全问题
- 掌握 synchronized 锁对象语法
- 掌握 synchronzied 加载成员方法和静态方法语法
- 掌握 wait/notify 同步方法
- 使用 lock 互斥解决临界区的线程安全问题
- 掌握 lock 的使用细节:可打断、锁超时、公平锁、条件变量
- 学会分析变量的线程安全性、掌握常见线程安全类的使用
- 了解线程活跃性问题:死锁、活锁、饥饿
- 应用方面
- 互斥:使用 synchronized 或 Lock 达到共享资源互斥效果
- 同步:使用 wait/notify 或 Lock 的条件变量来达到线程间通信效果
- 原理方面
- monitor、synchronized 、wait/notify 原理
- synchronized 进阶原理
- park & unpark 原理
- 模式方面
- 同步模式之保护性暂停
- 异步模式之生产者消费者
- 同步模式之顺序控制
本文作者:王陸
本文链接:https://www.cnblogs.com/wkfvawl/p/15421664.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
2019-10-18 操作系统——页式存储管理
2018-10-18 A Bug's Life(加权并查集)