写点什么

Java—线程安全

作者:武师叔
  • 2022 年 6 月 14 日
  • 本文字数:8842 字

    阅读完需:约 29 分钟

Java—线程安全

线程安全


Java 内存模型

多线程风险

在 Java 程序中,存储数据的内存空间分为共享内存和本地内存。线程在读写主存的共享变量时,会先将该变量拷贝一份副本到自己的本地内存,然后在自己的本地内存中对该变量进行操作,完成操作之后再将结果同步至主内存。



  • 优势:由于 CPU 执行速度明先快于内存读写速度,将运算需要的数据拷贝到 CPU 高速缓存中运算,可以大大加快程序运行速度。

  • 劣势:主内存数据和本地内存的不同步,导致多个线程同时操作主内存里的同一个变量时,变量数据可能会遭到破坏。


public class ThreadDemo {    public static void main(String[] args) {        MyThread t = new MyThread();        Thread t1 = new Thread(t);        Thread t2 = new Thread(t);        t1.start();        t2.start();    }}
class MyThread implements Runnable { private int x = 0; // 对象中的数据由线程共享 @Override public void run() { for (int i = 0; i < 10000; i++) { x++; } System.out.println("final x: " + x); // 最后输出的数据不一定为 20000 }}Copy to clipboardErrorCopied
复制代码

行为规范

JMM 定义了共享内存系统中多线程程序读写操作行为的规范,用来保证共享内存的原子性、可见性、有序性。


原子性

原子性是指一个操作,要么全部执行并且执行过程不会被打断,要么就都不执行。


  • Java 语言本身只保证了基本类型变量的读取和赋值是原子性操作。

  • 简单操作的原子性可以通过 Atomic 原子类实现。

  • 通过 synchronized 和 ReenTrantLock 等锁结构可以保证更大范围的原子性。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。


  • Java 语言会尽可能保证主内存数据和本地内存同步,但仍可能出现不可见问题。

  • 通常用 volatile 关键字来保证可见性。

  • 通过 synchronized 和 ReenTrantLock 等锁结构在释放锁之前会将对变量的修改刷新到主存当中,也能够保证可见性。

有序性

有序性是指程序执行的顺序按照代码的先后顺序执行。


  • Java 内存模型具备先天的有序性。但 Java 允许编译器和处理器对指令进行重排序,可能影响多线程并发执行时的有序性。

  • 通过 synchronized 和 ReenTrantLock 等锁结构可以保证有序性。

  • volatile 关键字可以禁止 JVM 的指令重排,也可以保证有序性。

线程锁

互斥锁和自旋锁

  • 互斥锁


阻塞锁。当线程需要获取的锁已经被其他线程占用时,该线程会被直接挂起。直到其他线程释放锁,由操作系统激活线程。


适用于锁使用者保持锁时间比较长的情况,线程挂起后不再消耗 CPU 资源。


  • 自旋锁


非阻塞锁。当线程需要获取的锁已经被其他线程占用时,该线程会不断地消耗 CPU 的时间去试图获取锁。


适用于锁使用者保持锁时间比较短的情况,没有用户态和内核态调度、上下文切换的开销和损耗。

悲观锁和乐观锁

  • 悲观锁


每次读写资源时都会给资源上锁,其他线程想获取该资源时会被阻塞,直到其释放锁。


适用于写频繁的应用场景,写资源请求不会被一直驳回。synchronized 和 ReentrantLock 等独占锁都是悲观锁。


  • 乐观锁


读资源时不会给资源上锁,多个线程可以同时读取资源。写资源时会比对数据检查其他线程有没有更新过该资源,如果未更新就写入资源并更新版本号,否则写资源请求被驳回,重新读取并写资源。


适用于读频繁的应用场景,多线程同时读取能有效提高吞吐量。CAS 算法和版本号机制都是乐观锁,悲观锁的抢占也会利用 CAS 算法。

公平锁和非公平锁

  • 公平锁


加入到队列中等待唤醒,先到者先拿到锁。


公平锁不会出现线程饥饿,迟迟无法获取锁的情况。ReentrantLock 可以实现公平锁。


  • 非公平锁


当线程要获取锁时通过两次 CAS 操作去抢锁,如果没抢到加入到队列中等待唤醒。


非公平锁的性能更好。synchronized 是非公平锁,ReentrantLock 默认情况下也是非公平锁。

可重入锁

允许一个线程对同一对象多次上锁。由 JVM 记录对象被线程加锁次数,只有当线程释放掉所有锁(加锁次数为 0)时,其他线程才获准进入。


synchronized 和 ReentrantLock 等锁结构都是可重入锁。



底层实现

volatile 关键字

修饰成员变量,每次被线程访问时,强迫从主存中读写该成员变量的值。


volatile 关键字只能保证可见性,不能保证原子性。多个线程同时操作主内存里的同一个变量时,变量数据仍有可能会遭到破坏。


  • 线程执行过程中如果 CPU 一直满载运转,就会默认使用本地内存中的值,而没有空闲读取主存同步数据。

  • 线程执行过程中一旦 CPU 获得空闲,JVM 也会自动同步主存数据,尽可能保证可见性。


public class ThreadDemo {    public static void main(String[] args) throws InterruptedException {        MyThread t = new MyThread();        t.start();        try {             Thread.sleep(1000);         } catch(InterruptedException e) {}        t.setRun(false);          }}
class MyThread extends Thread { // 添加 volatile 关键字,强制同步主存数据。 // 删除 volatile 关键字,子线程将始终读取本地内存中 true 副本:陷入死循环。 private volatile boolean run = true; public void setRun(boolean run) { this.run = run; } @Override public void run() { while (this.run == true) { int a = 2; int b = 3; int c = a + b; // System.out.print("CPU rest"); 打印输出时 CPU 获得空闲,自动同步主存数据。 } System.out.print("end"); return; }}Copy to clipboardErrorCopied
复制代码

synchronized 关键字

修饰方法或代码块。被线程访问时由线程抢占锁,直到执行完毕后自动释放锁。其他线程没有获得锁将无法访问上锁内容。保证了指定内容在同一时刻只有一个线程能访问。


  1. 修饰 static 方法实质是给当前类上锁:这个类的所有 synchronized static 方法共享一个锁。

  2. 修饰实例方法实质是给对象上锁:这个对象内所有的 synchronized 实例方法共享一个锁。


每一个对象都有且仅有一个与之对应的 monitor 对象。synchronized 关键字修饰方法时会对方法添加标志位,当线程执行到某个方法时,JVM 会去检查该方法的访问标志是否被设置,如果设置了线程会先获取这个对象所对应的 monitor 对象,再执行方法体,方法执行完后释放 monitor 。


同步代码块则是在同步代码块前插入 monitorenter ,在同步代码块结束后插入 monitorexit 。


public class ThreadDemo {    public static void main(String[] args) {        ThreadDemo test = new ThreadDemo();        new Thread(test::m1).start();        new Thread(test::m2).start();       }
public synchronized void m1() { System.out.println("1"); try { Thread.sleep(1000); } catch(InterruptedException e) {} System.out.println("2"); }
public synchronized void m2() { System.out.println("3"); try { Thread.sleep(500); } catch(InterruptedException e) {} System.out.println("4"); }}Copy to clipboardErrorCopied
复制代码

同步对象

创建两个不同的对象就拥有两把不同的锁,不同对象的 synchronized 实例方法互不影响。


public class ThreadDemo {    public static void main(String[] args) {        ThreadDemo test1 = new ThreadDemo();        ThreadDemo test2 = new ThreadDemo();        new Thread(test1::m1).start();        new Thread(test2::m2).start();     }
public synchronized void m1() { System.out.println("1"); try { Thread.sleep(1500); } catch(InterruptedException e) {} System.out.println("4"); }
public synchronized void m2() { try { Thread.sleep(500); } catch(InterruptedException e) {} System.out.println("2"); try { Thread.sleep(500); } catch(InterruptedException e) {} System.out.println("3"); }}Copy to clipboardErrorCopied
复制代码

同步方法

其他线程无法获取该对象锁,就不能访问该对象的所有 synchronized 实例方法,但仍可以访问其他方法。 synchronized 实例方法中调取的数据仍可能被其他方法修改。


在实际开发过程中,我们常常对写操作加锁,但对读操作不加锁,提升系统的并发性能。但可能会导致脏读问题。


public class ThreadDemo {    public static void main(String[] args) {        ThreadDemo test = new ThreadDemo();        new Thread(test::m1).start();        new Thread(test::m2).start();     }
boolean data = false;
public synchronized void m1() { System.out.println(data); // false try { Thread.sleep(1000); } catch(InterruptedException e) {} System.out.println(data); // true }
public void m2() throws { try { Thread.sleep(500); } catch(InterruptedException e) {} this.data = true; }}Copy to clipboardErrorCopied
复制代码

同步代码块

如果我们需要同步的代码只有一小部分,就没有必要对整个方法进行同步操作,我们只需要同步的代码块进行包裹。


修饰代码块,需要指定被上锁的对象或者类。每次线程进入 synchronized 代码块时就会要求当前线程持有该对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行操作。我们通常使用 this 对象或者当前类的 class 对象作为锁。


不要以字符串对象作为锁的对象。字符串常量在常量池里被锁定,可能会导致意想不到的阻塞。


public class ThreadDemo {    public static void main(String[] args) {        ThreadDemo test = new ThreadDemo();        new Thread(test::m1).start();        new Thread(test::m2).start();       }
public void m1() { synchronized(this) { System.out.println("1"); try { Thread.sleep(1000); } catch(InterruptedException e) {} System.out.println("2"); } }
public synchronized void m2() { synchronized(this) { System.out.println("3"); try { Thread.sleep(500); } catch(InterruptedException e) {} System.out.println("4"); } }}Copy to clipboardErrorCopied
复制代码


线程执行代码出现异常时也会自动释放所有锁,因此在 synchronized 内部处理异常一定要非常小心。如果不想释放锁,使用 try-catch 语句捕获异常。

两者的区别

  1. volatile 关键字用于修饰变量,synchronized 关键字用于修饰方法以及代码块。

  2. volatile 关键字是数据同步的轻量级实现,性能比 synchronized 关键字更好。

  3. volatile 关键字被多线程访问不会发生阻塞,synchronized 关键字可能发生阻塞。

  4. volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。


// 双重锁结构实现单例模式
public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }Copy to clipboardErrorCopied
复制代码



ReentrantLock 锁

实现 Lock 接口,使用时需导入 import java.util.concurrent.locks.*;


实现功能和 synchronized 关键字类似。但 synchronized 关键字是在 JVM 层面实现的,而 ReenTrantLock 是在 JDK 层面实现的。需要手动调用 lock 和 unlock 方法配合 try/finally 语句块来完成。


public class ReentrantLockTest {    // 创建锁对象    static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException { for(int i = 0; i < 5; i++){ new Thread(new MyThread()).start(); } }
static class MyThread implements Runnable { @Override public void run() { try { // 加锁,通常在 try 语句里完成 lock.lock(); Thread.sleep(500); System.out.println(Thread.currentThread().getName() + "excute"); } catch (InterruptedException e) {} finally{ // 解锁,必须在 finally 语句里完成 lock.unlock(); } } } }}Copy to clipboardErrorCopied
复制代码


ReenTrantLock 比 synchronized 增加了一些高级功能,主要有以下三点:

实现等待中断

调用 lockInterruptibly 方法上锁,线程中断标志置为 true 时会抛出 InterruptedException 异常并释放锁。防止线程因为无法获得锁而一直等待,常用来从外部破坏线程死锁。


public class ThreadDemo {    // 创建锁对象    static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new MyThread(),"thread1"); Thread t2 = new Thread(new MyThread(),"thread2"); t1.start(); t2.start(); Thread.sleep(500); // 提前中断线程 t2.interrupt(); }
static class MyThread implements Runnable { @Override public void run() { System.out.println(Thread.currentThread().getName() + "begin"); try { // 加可中断锁 lock.lockInterruptibly(); Thread.sleep(2000); } catch (InterruptedException e) { System.out.println(Thread.currentThread().getName() + "out"); } finally{ try{ lock.unlock(); } catch(IllegalMonitorStateException e) {} System.out.println(Thread.currentThread().getName() + "end"); } } }}Copy to clipboardErrorCopied
复制代码


调用 tryLock 方法上锁,可以从线程内部破坏死锁,可以更好地解决死锁问题。


  • 传入时间参数设定等待锁的时间,超时没有获得锁则中止。

  • 无参则返回锁申请的结果:true 表示获取锁成功,false 表示获取锁失败。


public class ThreadDemo {    // 创建锁对象    static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new MyThread(),"thread1"); Thread t2 = new Thread(new MyThread(),"thread2"); t1.start(); t2.start(); }
static class MyThread implements Runnable { @Override public void run() { System.out.println(Thread.currentThread().getName() + "begin"); try { // 加锁失败直接退出 if(!lock.tryLock()) { System.out.println(Thread.currentThread().getName() + "out"); return; } Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } finally{ try{ lock.unlock(); } catch(IllegalMonitorStateException e) {} System.out.println(Thread.currentThread().getName() + "end"); } } }}Copy to clipboardErrorCopied
复制代码

实现公平锁

允许先等待的线程先获取锁,防止线程因无法获得锁而一直等待。但由于性能优势,默认情况下仍使用非公平锁。在构造锁对象时添加参数 true 即可实现。


import java.util.concurrent.locks.*;
public class ReentrantLockTest { // 创建锁对象,且声明为公平锁 static Lock lock = new ReentrantLock(true);
public static void main(String[] args) throws InterruptedException { for(int i = 0; i < 5; i++){ new Thread(new MyThread()).start(); } }
static class MyThread implements Runnable { @Override public void run() { lock.lock(); try { Thread.sleep(500); System.out.println(Thread.currentThread().getName() + "excute"); } catch (InterruptedException e) {} lock.unlock(); } } }}Copy to clipboardErrorCopied
复制代码

选择性通知

ReentrantLock 对象可以创建一个或多个 Condition 对象,实现线程间的等待通知机制。比 synchronized 关键字 使用 wait/notify 方法更为简便和易用。


线程获得 Lock 锁之后便可调用 Condition 接口的 await 方法释放锁并等待,直到有其他线程调用 Condition 的 signal 方法唤醒线程。通过设置多个 condition 对象,多个线程等待不同的 condition 对象,可以实现选择性地叫醒线程。


public class ThreadDemo {
static ReentrantLock lock = new ReentrantLock(); static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException { lock.lock(); new Thread(new MyThread()).start(); System.out.println("主线程等待通知"); try { condition.await(); } finally { lock.unlock(); } System.out.println("主线程恢复运行"); }
static class MyThread implements Runnable { @Override public void run() { lock.lock(); try { condition.signal(); System.out.println("子线程通知"); } finally { lock.unlock(); } } }}Copy to clipboardErrorCopied
复制代码



Atomic 原子类

原子对象的单个方法具有原子性,通过 CAS 算法和自旋操作实现,并发效率高。使用时需导入 import java.util.concurrent.atomic.*


public class ThreadDemo {
public static void main(String[] args) { MyThread t = new MyThread(); Thread t1 = new Thread(t); Thread t2 = new Thread(t); t1.start(); t2.start(); }}
class MyThread implements Runnable { AtomicInteger count = new AtomicInteger(0); // 定义整型地原子类 @Override public void run() { for (int i = 0; i < 10000; i++) { count.incrementAndGet(); // 原子性自增操作 } System.out.println("final x: " + count); // 最后输出的数据为 20000 }}Copy to clipboardErrorCopied
复制代码



ThreadLocal 线程本地对象

ThreadLocal 类会对每一个线程创建一个副本,用来保存其私有的数据,其他线程无法访问。简单方便且并发性好,在开发框架中被大量使用,比如 session 管理。


ThreadLocal 方法


ThreadLocal threadLocal = new ThreadLocal();        // 构造 ThreadLocalThreadLocal<T> threadLocal = new ThreadLocal<>();   // 支持泛型
threadLocal.get(); // 获取当前线程中保存的变量副本threadLocal.set(10); // 设置当前线程中变量的副本threadLocal.remove(); // 移除当前线程中变量的副本Copy to clipboardErrorCopied
复制代码


ThreadLocal 示例


// 两个线程从同一个 MyThread 对象取值,但结果不同。public class ThreadLocalExample {    public static void main(String[] args) {        MyThread t = new MyThread();        Thread thread1 = new Thread(t);        Thread thread2 = new Thread(t);        thread1.start();        thread2.start();    }​}
class MyThread implements Runnable {
private ThreadLocal threadLocal = new ThreadLocal();
@Override public void run() { threadLocal.set((int) (Math.random() * 100D)); try { Thread.sleep(2000); } catch (InterruptedException e) {} System.out.println(threadLocal.get()); }}
复制代码


发布于: 刚刚阅读数: 4
用户头像

武师叔

关注

每天丰富自己,去过自己想要的生活! 2022.04.28 加入

一个喜欢最新技术,研发的人工智能专业的大二学生,用自己的代码做一些有意义的事情! 目前大二结束有去大厂研发岗实习的计划,每天丰富自己的技术,去过自己想要的实习生活。

评论

发布
暂无评论
Java—线程安全_6月月更_武师叔_InfoQ写作社区