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

简介: 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)适合读操作多的场景,不加锁的特性能够使其读操作的性能大幅提升,乐观锁则直接去操作同步资源,是一种无锁算法。但是会存在成功率的问题,因为如果当时有其他线程在修改,当前线程有可能会修改失败

(4)例子:写更新库存的SQL时,加上条件判断与查询时库存数量是否一致,不一致则不允许修改

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天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
5天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
25 9
|
8天前
|
Java
JAVA多线程通信:为何wait()与notify()如此重要?
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是实现线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件满足时被唤醒,从而确保数据一致性和同步。相比其他通信方式,如忙等待,这些方法更高效灵活。 示例代码展示了如何在生产者-消费者模型中使用这些方法实现线程间的协调和同步。
22 3
|
13天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
41 4
|
23天前
|
安全 Java
Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧
【10月更文挑战第20天】Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧,包括避免在循环外调用wait()、优先使用notifyAll()、确保线程安全及处理InterruptedException等,帮助读者更好地掌握这些方法的应用。
15 1
|
23天前
|
Java
在Java多线程编程中,`wait()`和`notify()`方法的相遇如同一场奇妙的邂逅
在Java多线程编程中,`wait()`和`notify()`方法的相遇如同一场奇妙的邂逅。它们用于线程间通信,使线程能够协作完成任务。通过这些方法,生产者和消费者线程可以高效地管理共享资源,确保程序的有序运行。正确使用这些方法需要遵循同步规则,避免虚假唤醒等问题。示例代码展示了如何在生产者-消费者模型中使用`wait()`和`notify()`。
24 1
|
23天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
33 1
|
23天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
24 1
|
1月前
|
存储 安全 Java
Java-如何保证线程安全?
【10月更文挑战第10天】
|
存储 缓存 Java
Java 中的伪共享详解及解决方案
Java 中的伪共享详解及解决方案
274 0
Java 中的伪共享详解及解决方案