Java 中的线程安全性问题——synchronized / Lock

线程安全性

    • 线程不安全因素
    • volatile 解决内存可见性和指令重排序
    • 内置锁 synchronized
      • - synchronized 基本使用
      • - synchronized 特性
      • - synchronized 是如何实现的?
      • - synchronized 执行流程
    • Lock
      • Lock 用法及注意事项
      • Lock 公平锁和非公平锁
    • synchronized 和 Lock 的区别

多线程的“共享性”,意味着在程序中的变量可以由多个线程同时访问。而“可变性”则意味着变量的值在其生命周期内可以发生变化。本篇博客记录在 Java 的多线程学习中如何防止多个线程在数据上发生不受控的并发访问~

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象的可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。 通过以下示例来解释:

public class ThreadDemo_线程不安全示例 {
    private static int number = 0;
    static class Counter {
        // 循环次数
        private static int MAX_COUNT = 1000000;
        // ++ 方法
        public static void incr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                number++;
            }
        }
        // -- 方法
        public static void decr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                number--;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            Counter.incr();
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            Counter.decr();
        });
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();
        System.out.println("最终的结果:" + number);
    }
}

这个代码很容易看出来,过程是给定了一个循环次数,在 线程1 中对其进行 ++ 操作,在 线程2 中进行相同次数的 - - 操作。想要得到的最终结果当然是 0 了,但是以上代码的运行结果:
Java 中的线程安全性问题——synchronized / Lock_第1张图片
是的,每次的结果都是随机的也是不正确的,这就是因为多个线程无法实现协同,导致了数据破坏出现了不该出现的结果~

那么就引出:

当多个线程访问某个状态的变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些变量的访问。 Java 中的主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量。

线程不安全因素

以上笼统的介绍了线程安全性的问题,那么在解决问题之前有必要先来明确一下 Java 中导致线程不安全的因素~~~

  1. 抢占式执行
    java中线程调度采用抢占式调度方法,抢占式调度模式,许多线程可能是可运行状态但只能有一个线程在运行该线程将持续运行直到它自行终止或者是由于其他的事件导致阻塞亦或者是出现高优先级线程成为可运行的则该线程失去CPU的占用权。而非其他编程语言采用有轮回式的方式。
  2. 多个线程修改同一个变量
    Java 中的线程安全性问题——synchronized / Lock_第2张图片
    在多线程共享的资源中,我们所定义一个变量 count :存放在堆(Heap)上的变量, 此时这个 count 是一个多个线程都能访问到的 “共享数据” 。那么就像篇头第一个示例一样,如果我们在多个线程中修改重新写入同一个类变量,那么就会出现线程不安全的问题,不能达到代码正确的结果。
  3. 非原子性操作
    对于比如 count++ 操作,它并不是一个原子性操作,它的操作是分为三步的:1. 查询 count 当前的值【load】 2.进行 count+1 操作【++】 3. 刷新 count 的最新值【save】。
    当在两个线程中同时执行时,
    Java 中的线程安全性问题——synchronized / Lock_第3张图片
    想要得到的结果是0但是最终的结果却不是我们想要的也不是我们可以控制的。
  4. 内存可见性问题
    可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java内存模型(JMM):
Java 中的线程安全性问题——synchronized / Lock_第4张图片

  • 线程之间的共享变量存在主内存 (Main Memory).

  • 每一个线程都有自己的 “工作内存” (Working Memory) .

  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据. 当线程要修改一个共享变量的时候,
    也会先修改工作内存中的副本, 再同步回主内存。

如下场景:
① 初始情况下, 两个线程的工作内存内容一致。
② 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的 工作内存的 a 的值也不一定能及时同步。

这就是内存的不可见性~

  1. 指令重排序
    指令重排序是编译器优化代码的一种操作,咱们在写的有些代码中,其实彼此的顺序(不会影响程序逻辑),谁在前谁在后无所谓~编译器会有只能的调整代码的前后顺序从而提高程序效率。 那么如果是一个多线程的程序,编译器在优化代码时出现了误判,优化了不应该更改的顺序。就也会导致线程安全问题。

volatile 解决内存可见性和指令重排序

volatile 可以解决内存可⻅性和指令重排序的问题,代码在写⼊ volatile 修饰的变量的时候:

  1. 改变线程⼯作内存中 volatile 变量副本的值
  2. 将改变后的副本的值从⼯作内存刷新到主内存 代码在读取 volatile 修饰的变量的时候:
  3. 从主内存中读取 volatile 变量的最新值到线程的⼯作内存中
  4. 从工作内存中读取 volatile 变量的副本

简单来说,他就是禁止了编译器指令重排序这一优化操作,也通过变量副本的方式保证了内存可见性~

public class TheradDemoVolatile {
    private static volatile boolean flag = false;
    public static void main(String[] args){
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程1 开始执行");
                while (!flag) {

                }
                System.out.println("终⽌执⾏");
            }
        });
        t1.start();
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("在线程2 中设置 flag=true");
                flag = true;
            }
        });
        t2.start();
    }
}


如上代码,如果对 flag 变量不加 volatile 修饰,则永远执行不到System.out.println("终⽌执⾏");这行代码。因为在线程2 中的修改对线程1 不可见~ 当用 volatile 修饰之后,能够解决这个代码中的线程安全问题。
Java 中的线程安全性问题——synchronized / Lock_第5张图片
不足: volatile 虽然可以解决内存可⻅性和指令重排序的问题,但是解决不了原⼦性问题,因此对于 ++ 和 - - 操作的线程不安全问题依然解决不了

内置锁 synchronized

Java提供了一种内置锁机制来支持原子性~以关键字 synchronized来修饰的方法就是一钟横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。其中静态的 synchronized 方法以Class 对象作为锁。

	//定义一个任意的对象
    Object myLock = new Object();

    synchronized(myLock) {
		//访问或修改由锁保护的共享状态
    }

Java 中的线程安全性问题——synchronized / Lock_第6张图片

- synchronized 基本使用

  • ① 修饰静态⽅法
/**
 * synchronized 修饰静态方法
 */
public class ThreadSynchronized {
    private static int number = 0;

    static class Counter {

        // 循环次数
        private static int MAX_COUNT = 1000000;

        // ++ 方法
        public synchronized static void incr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                number++;
            }
        }

        // -- 方法
        public synchronized static void decr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                number--;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            Counter.incr();
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            Counter.decr();
        });
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();
        System.out.println("最终的结果:" + number);
    }
}
  • ② 修饰普通⽅法
/**
 * synchronized 修饰普通方法
 */
public class ThreadSynchronized2 {
    private static int number = 0;

    static class Counter {

        // 循环次数
        private static int MAX_COUNT = 1000000;

        // ++ 方法
        public synchronized void incr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                number++;
            }
        }

        // -- 方法
        public synchronized void decr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                number--;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            counter.incr();
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            counter.decr();
        });
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();
        System.out.println("最终的结果:" + number);
    }
}
  • ③ 修饰代码块
/**
 * synchronized 修饰代码
 */
public class ThreadSynchronized3 {
    private static int number = 0;

    static class Counter {

        // 循环次数
        private static int MAX_COUNT = 1000000;

        // 自定义锁对象(属性名可以自定义)
        private Object mylock = new Object();

        // ++ 方法
        public void incr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                synchronized (mylock) {
                    number++;
                }
            }
        }

        public static void test() {
            synchronized (Counter.class) {

            }
        }

        // -- 方法
        public void decr() {
            for (int i = 0; i < MAX_COUNT; i++) {
                synchronized (mylock) {
                    number--;
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            counter.incr();
        });
        t1.start();

        Thread t2 = new Thread(() -> {
            counter.decr();
        });
        t2.start();

        // 等待线程执行完成
        t1.join();
        t2.join();
        System.out.println("最终的结果:" + number);
    }
}

- synchronized 特性

  • 互斥(排他性)

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也 执行到同一个对象 synchronized 就会阻塞等待.

  • 刷新内存(内存不可见问题)

synchronized 的⼯作过程:

  1. 获得互斥锁
  2. 从主内存拷⻉变量的最新副本到⼯作的内存
  3. 执⾏代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁
  • 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。

/**
 * synchronized 可重入性测试
 */
public class ThreadSynchronized {
    public static void main(String[] args) {
        synchronized (ThreadSynchronized.class) {
            System.out.println("当前主线程已经得到了锁");
            synchronized (ThreadSynchronized.class) {  //可以进入第二层
                System.out.println("当前主线程再次得到了锁");
            }
        }
    }
}

在这里插入图片描述

- synchronized 是如何实现的?

我们观察一下一个被 synchronized 修饰加锁的代码在 JVM 层面的字节码是如何实现的:
Java 中的线程安全性问题——synchronized / Lock_第7张图片
结论:
synchronized 同步锁是通过 JVM 内置的 Monitor 监视器实现的,而监视器又是依赖操作系统的互斥锁 Mutex 实现的。JVM 监视器的执行流程是:线程先通过自旋 CAS 的方式尝试获取锁,如果获取失败就 进⼊ EntrySet 集合,如果获取成功就拥有该锁。当调用 wait() ⽅法时,线程释放锁并进⼊ WaitSet 集合,等其他线程调用 notify 或 notifyAll 方法时再尝试获取锁。锁使用完之后就会通知 EntrySet 集合中的线程,让它们尝试获取锁。

链接: link.

- synchronized 执行流程

在 Java 中,synchronized 是非公平锁,也是可以重入锁。

- 所谓的非公平锁是指,线程获取锁的顺序不是按照访问的顺序先来先到的,而是由线程自己竞争,随机获取到锁。
- 可重入锁指的是,一个线程获取到锁之后,可以重复得到该锁。也就是多层的获取进入这个锁

ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0,  
  _recursions   = 0;       //线程的重⼊次数
  _object       = NULL;  
  _owner        = NULL;    //标识拥有该monitor的线程
  _WaitSet      = NULL;    //等待线程组成的双向循环链表,_WaitSet是第⼀个节点
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;    //多线程竞争锁进⼊时的单向链表
  FreeNext      = NULL ;  
  _EntryList    = NULL ;    //_owner从该双向循环链表中唤醒线程结点,_EntryList
是第⼀个节点
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
}

监视器执行的流程如下:
Java 中的线程安全性问题——synchronized / Lock_第8张图片

  1. 线程通过 CAS(对比并替换) 尝试获取锁,如果获取成功,就将 _owner 字段设置为当前线程 id,说明当前线程已经持有锁,并将_recursions 重入次数的属性+1。如果获取锁失败则先通过自旋 CAS 再尝试获取锁,如果还是获取失败就将当前线程放入 EntryList 监控队列(阻塞)。
  2. 当拥有锁的线程执行了 wait 方法之后,线程释放锁,将 _owner 变量恢复为 NULL 状态,同时将该线程放入 WaitSet 待授权队列中等待被唤醒。
  3. 当调用 notift 方法时,随机唤醒 WaitSet 队列中的某一个线程;当调用 notifyAll 时唤醒所有的 WaitSet 中的线程尝试获取锁。
  4. 线程执行完释放了锁之后,会唤醒 EntryList 中的所有尝试去获取锁。

Lock

Lock 用法及注意事项

Lock 是一个接口,一般使用 ReentrantLock 类作为锁。在加锁和解锁处需要通过 lock() 和 unlock() 显示指出。所以一般会在 finally 块中写 unlock() 以防死锁

	lock.lock();
	try {
            
	}finally {
   	     lock.unlock();
	}
  • 注意1 :把 unlock() 放在 finally 块中以防死锁
  • 注意2 :把 lock() 放在 try 外或者 tyr 的首行,因为1. try 代码中的异常导致加锁失败,还会执行 finally 释放锁操作。2. 释放锁的错误信息会覆盖业务代码报错信息,从而增加调试程序和修复程序的复杂度。

Lock 公平锁和非公平锁

构造方法 :

  • ReentrantLock() : 创建一个 ReentrantLock的实例。

  • ReentrantLock(boolean fair) : 根据给定的公平政策创建一个 ReentrantLock的实例。

Java 中的线程安全性问题——synchronized / Lock_第9张图片
相同程序执行时间对比Java 中的线程安全性问题——synchronized / Lock_第10张图片

synchronized 和 Lock 的区别

区别:1、lock是一个接口,而synchronized是java的一个关键字。2、synchronized在发生异常时会自动释放占有的锁,因此不会出现死锁;而lock发生异常时,不会主动释放占有的锁,必须手动来释放锁,可能引起死锁的发生。

类别 synchronized Lock
存在层次 Java的关键字,在jvm层面上 是一个类
锁的释放 1、以获取锁的线程执行完同步代码,释放锁; 2、线程执行发生异常,jvm会让线程释放锁 在finally中必须释放锁,不然容易造成线程死锁
锁的获取 假如A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 分情况而定,Lock有多个锁获取的方式,可以尝试继续获取锁,线程不用一直等待
锁的状态 无法判断 可以判断
锁类型 可重入、不可中断、非公平 可重入、可判断、可公平
性能 少量同步 大量同步

区别如下:

  • 来源:
    Lock 是一个接口,而 synchronized 是 java 的一个关键字,synchronized 是内置语言实现;
  • 异常是否释放锁
    synchronized 在发生异常时会自动释放占有的锁,因此不会出现死锁;而 Lock 发生异常的时候,不会主动释放占有的锁,必须手动 unlock() 来释放锁,可能引起死锁的发生。(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生。)
  • 是否相应中断
    Lock 等待锁过程中可以用 interrupt 来中断等待,而 synchronized 只能等待锁的释放,不能响应中断;
  • 是否知道获取锁
    Lock 可以通过 trylock() 来知道有没有获取锁,而synchronized 不能;
  • Lock 可以提高多个线程进行读操作的效率。
  • synchronized 使用 Object 对象本身的 wait、notify、notifyAll 调度机制,而 Lock 可以使用 Condition 进行线程之间的调度。
//Condition定义了等待/通知两种类型的方法
Lock lock=new ReentrantLock();
Condition condition=lock.newCondition();...condition.await();...condition.signal();
condition.signalAll();
  1. synchronized 和 Lock 的用法区别
    synchronized :在需要同步的对象中加入此控制, synchronized 可以加在方法上,也可以加在特定代码块中,括号中表示用来加锁的对象。
    Lock:一般使用 ReentrantLock 类作为锁。在加锁和解锁处需要通过处需要通过 lock() 和 unlock() 显示指出。所以一般会在finally块中写unlock()以防死锁。
  2. synchronized 和 lock性能区别
    synchronized 是托管给 JVM 执行的,
    而 Lock 是 java 写的控制锁的代码。

在 Java1.5 中,synchronized 是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁外的操作还多。相比之下使用 Java 提供的 Lock 对象,性能更高一些。

但是到了 Java1.6,发生了变化。synchronized 在语法上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronize的性能并不比Lock差。

2种机制的具体区别:
synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。 独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低。
而Lock用的是乐观锁方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。 乐观锁实现的机制就是CAS操作(Compare and Swap)。我们可以进一步研究ReentrantLock的源代码,会发现其中比较重要的获得锁的一个方法是compareAndSetState。这里其实就是调用的CPU提供的特殊指令。

Java 中的线程安全性问题——synchronized / Lock_第11张图片

现代的CPU提供了指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。这个算法称作非阻塞算法,意思是一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。

  1. synchronized 和 Lock 用途区别
    synchronized 原语和 ReentrantLock 在一般情况下没有什么区别,但是在非常复杂的同步应用中,请考虑使用ReentrantLock,特别是遇到下面2种需求的时候。
  • 某个线程在等待一个锁的控制权的这段时间需要中断
  • 需要分开处理一些wait-notify,ReentrantLock里面的Condition应用,能够控制notify哪个线程
  • 具有公平锁功能,每个到来的线程都将排队等候。

你可能感兴趣的:(笔记,多线程,java)