计算机系统应用教程网站

网站首页 > 技术文章 正文

Java锁机制之五大特性 java中的锁机制

btikc 2024-10-08 01:17:46 技术文章 54 ℃ 0 评论

并发编程是Java开发者最重要的技能之一,也是最难掌握的一种技能。它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰、思维缜密,这样才能写出高效、安全、可靠的多线程并发程序。

Java 内存模型中

一、共享性

数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是在编程的时候经常不需要考虑线程安全的主要原因之一。但是,在多线程编程中,数据共享是不可避免的。

二、互斥性

  资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。

所以通常将锁分为共享锁和排它锁,也叫做读锁和写锁

例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。但是对共享数据的写操作,一般就需要保证互斥性,

三、原子性

  原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,即一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。

保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。但是很多操作不能通过一条指令就完成。

eg: 对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,开发中经常使用的整数 i++ 的操作,其实需要分成三个步骤:

(1)读取整数 i 的值;

( 2)对 i 进行加一操作;

(3)将结果写回内存

这个过程在多线程下就可能出现如下现象:


对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的Synchronized或Lock都可以实现

除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理

不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。

备注:

保证数据原子性方案:

  • 加锁
  • CAS(Compare And Swap)

四、可见性

共享数据修改的可见性。

每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,这么做的目的还是在于进一步缩小存储系统与CPU之间速度的差异,提高性能),对于共享资源,线程每次读取的是工作内存中共享资源的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。

这样导致的问题是,如果线程1对某个资源进行了修改,线程2却有可能看不到线程1对共享资源所做的修改。

JVM的内存模型与操作系统类似,如图所示:


 如何保证内存可见性?

  在java虚拟机的内存模型中,有主内存和工作内存的概念每个线程对应一个工作内存,并共享主内存的数据。

下面看看操作普通变量和volatile变量有什么不同:

  1、对于普通变量:读操作会优先读取工作内存的数据,如果工作内存中不存在,则从主内存中拷贝一份数据到工作内存中;写操作只会修改工作内存的副本数据,这种情况下,其它线程就无法读取变量的最新值。

  2、对于volatile变量,读操作时JMM会把工作内存中对应的值设为无效,要求线程从主内存中读取数据;写操作时JMM会把工作内存中对应的数据刷新到主内存中,这种情况下,其它线程就可以读取变量的最新值。

  volatile变量的内存可见性是基于内存屏障(Memory Barrier)实现的,什么是内存屏障?内存屏障,又称内存栅栏,是一个CPU指令。在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM为了保证在不同的编译器和CPU上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

eg:


public class VisibilityTest {
    private static boolean ready;
    private static int number;
    private static class ReaderThread extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (!ready) {
                System.out.println(ready);
            }
            System.out.println(number);
        }
    }

    private static class WriterThread extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            number = 100;
            ready = true;
        }
    }

    public static void main(String[] args) {
        new WriterThread().start();
        new ReaderThread().start();
    }
}


从直观上理解,这段程序应该只会输出100,ready的值是不会打印出来的。实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,下面是作者运行出来的某两次的结果:

当然,这个结果也只能说是有可能是可见性造成的,当写线程(WriterThread)设置ready=true后,读线程(ReaderThread)看不到修改后的结果,所以会打印false,对于第二个结果,也就是执行if (!ready)时还没有读取到写线程的结果,但执行System.out.println(ready)时读取到了写线程执行的结果。不过,这个结果也有可能是线程的交替执行所造成的。

备注

Java 中可通过Synchronized或Volatile来保证可见性。

扩展:

volatile 关键字

使变量在多个线程间可见(具有可见性),但是仅靠volatile是不能保证线程的安全性,volatile关键字不具备synchronized关键字的原子性。

五、有序性

保证字节码指令执行的有序性。

为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:

  (1)编译器优化的重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  (2)指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  (3)内存系统的重排序

由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

JSR 133 中对重排序问题的描述:



先看上左图源码部分,从源码来看,要么指令 1 先执行要么指令 3先执行。如果指令 1 先执行,r2不应该能看到指令 4 中写入的值。如果指令 3 先执行,r1不应该能看到指令 2 写的值。但是运行结果却可能出现r2==2,r1==1的情况,这就是“重排序”导致的结果。上右图即是一种可能出现的合法的编译结果,编译后,指令1和指令2的顺序可能就互换了。因此,才会出现r2==2,r1==1的结果。

备注:

Java 中也可通过Synchronized或Volatile来保证顺序性。

总结:

并发编程:

  • 数据资源的共享性互斥性;
  • 对数据资源操作的原子性,可见性
  • 保证线程字节码指令执行的有序性

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表