Java面试题-线程篇-(4)并发三大特性

2023-08-31  本文已影响0人  wyn_做自己

上一篇中有提到并发的三大特性:原子性、可见性、有序性,这一篇就详细来说一下这三大特性。

原子性:

Java原子性是指在多线程环境下,一段原子性的代码执行的时候是不会被打断的,这段代码要么全部完成,要么全部不完成,不会出现部分操作完成,部分操作没有完成的情况。这段代码可以是一行代码也可以是多行代码,一行代码很多也不是原子性的,多行代码加上锁了它也可以是原子性的。所以说原子性和代码的多少没有关系。原子性其实指的是cpu执行阶段的原子性。

来点示例说明一下:(1)int i = 10; 这行就是原子性的,就是定义一个变量i并赋值。因为他在cpu里面执行的时候就是一条指令。(2)long j = 13;这个就不是原子性的,因为long是64位长整型的,他在cpu执行的时候是两条指令,对高32位和低32位分别赋值。double也是如此,他也是64位的。(3)int i = 10; i++; 我们来看i++,在Java程序中它就是一行,但是在cpu级别其实是三条指令。第一条指令:获取i的值;第二条指令:执行i+1这个操作;第三条指令:把i+1这个结果赋值给i,所以i++就不是原子性操作了。

在Java中把多行语句加上锁(通常用synchronized或者lock对象来实现一段代码、一个方法、一个对象的加锁来实现一段代码的原子性),它就成了一个原子的了,从cpu的角度就是插入一个lock的指令,这段代码被lock指令锁住之后,只能一个线程执行这段代码了,其他线程就执行不了了,只能等这个线程释放掉锁资源,其他线程获取到这个锁资源才能执行这段代码。

下面是一个使用Java原子类的代码示例:

import java.util.concurrent.atomic.AtomicInteger;  
  
public class AtomicDemo {  
    private static AtomicInteger atomicInteger = new AtomicInteger(0);  
  
    public static void main(String[] args) {  
        Thread thread1 = new Thread(new Runnable() {  
            @Override  
            public void run() {  
                for (int i = 0; i < 1000; i++) {  
                    atomicInteger.incrementAndGet();  
                }  
            }  
        });  
  
        Thread thread2 = new Thread(new Runnable() {  
            @Override  
            public void run() {  
                for (int i = 0; i < 1000; i++) {  
                    atomicInteger.incrementAndGet();  
                }  
            }  
        });  
  
        thread1.start();  
        thread2.start();  
  
        try {  
            thread1.join();  
            thread2.join();  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
  
        System.out.println("最终结果: " + atomicInteger.get());  
    }  
}

最终执行的结果始终为:2000

image.png

在这个示例中,我们使用了AtomicInteger类来保证对变量atomicInteger的加操作是原子性的。在多线程环境下,两个线程同时对atomicInteger进行加操作,但是由于AtomicInteger类的保证,最终的结果一定是2000。如果不用AtomicInteger,结果是无法保证的,可能不是2000。可以参考上一篇非线程安全的情况。

可见性:

Java并发可见性是指在多线程的环境下,一个线程对共享变量的修改,能够立即被其他线程看到。

在Java中每一个子线程会有一个单独的工作内存,主线程有一个主内存,一个主内存有多个工作内存,主内存中存放的是共享变量,虽然说是共享变量,从名字来看感觉好像是主线程和子线程共享的,这样子线程就可以直接读写这个共享变量了,其实并不是,子线程是没有办法直接读写和操作这个共享变量的。实际上,子线程会在工作内存中创建一个共享变量的副本,而子线程只能操作这个共享变量的副本,读写完毕之后再将这个变量副本回写到主内存中。

来一个示例说明一下:主内存中有一个共享变量 i = 10; 有两个子线程A、B,这两个子线程都要操作共享变量i,这时线程A、B分别将共享变量i = 10 拷贝到自己的工作内存中,这样子线程A、B中就分别有了一个i = 10的变量副本。原来其实变量i只在主内存中,现在变量i变成了三份了,主内存、线程A工作内存、线程B工作内存各一份,如果线程A对变量i进行加1的操作,那么主线程中i = 11,而这时主线程和线程B中的i = 10;就出现了变量数据的值不一致了,这就是可见性的问题。

如何解决可见性的问题呢?

思路:线程A修改了i变量的副本值之后,马上把修改后的这个值回写到主内存。线程B读i变量副本的时候不再从自己的工作内存去读,而是从主内存重新加载一遍i的副本到自己的工作内存,相当于重新从主线程读变量i的值。

Java内存模型中是通过内存屏障来解决可见性的问题,内存屏障其实就是cpu级别的一个指令,将这个指令插入到其他指令之间。比如说原来有两个指令,在这两个指令之间插入一个内存屏障指令,这个内存屏障的指令会对前后两个指令做一些特殊的操作,它就可以解决这个可见性的问题。具体解决可见性问题使用的内存屏障是两个,一个是load屏障,一个是store屏障;load屏障:工作内存从主内存加载变量生成副本的指令。store屏障:将工作内存变量的副本回写到主内存的指令。load屏障作用:比如说现在有一个执行的指令A,它要读取一个共享变量的副本,现在在指令A前面插入一个load屏障指令,这时候指令A需要读的变量副本就失效了,也就是说运动到load屏障指令的时候,它就会让A需要用到的变量副本失效了,这时候指令A就读不到变量副本,然后指令A就要从主内存中再加载一次对应的变量到自己的工作内存,替换到当前的工作内存中的副本,然后才可以使用。store屏障作用:比如说现在有一个执行的指令B,它要修改一个变量工作内存副本的值,修改完成之后,我们在它之后插入一个store屏障指令,这个时候B修改过的那个变量的副本就会马上回写到主内存,因为store屏障指令就是把它前变修改过的变量的值马上回写到主内存。

下面是一个Java可见性的代码示例:

public class VisibilityDemo {
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                flag = true;
                System.out.println("t1 flag = true");
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!flag) {
                    // 循环等待flag变为true
                }
                System.out.println("t2 flag = true");
            }
        });

        t1.start();
        t2.start();
    }
}

执行结果:

image.png

在这个示例中,我们使用了一个volatile布尔变量flag。当flag被修改为true时,会立即被其他线程看到。在第二个线程中,我们使用了一个while循环来等待flag变为true。当flag变为true时,第二个线程会立即执行下一步操作。因此,我们可以保证在flag变为true时,所有线程都能够看到这个修改,并作出相应的反应。

有序性:

Java并发有序性是指在多线程的环境下,程序执行的顺序按照代码的先后顺序执行,禁止进行指令重排序。看似理所当然的事情,其实并不是这样,指令重排序是 JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。但是在多线程环境下,有些代码的顺序改变,有可能引发逻辑上的不正确。

在Java中,为了提高程序的运行效率,可能在编译期和运行期会对代码指令进行一定的优化,不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序执行,但也不是随意进行重排序,它会保证程序的最终运算结果是编码时所期望的。这种情况被称之为指令重排(Instruction Reordering)。

下面是一个Java可见性的代码示例:

public class SequentialDemo {
    private static int count = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (this) {
                    count++;
                    System.out.println("t1 count = " + count);
                }
            }
        });

        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (this) {
                    count++;
                    System.out.println("t2 count = " + count);
                }
            }
        });

        t1.start();
        t2.start();
    }
}

执行结果:


image.png

在这个示例中,我们使用了一个静态变量count。在第一个线程中,我们使用synchronized块来保证对count的修改是原子的,不会被其他线程中断。在第二个线程中,我们也使用了synchronized块来保证对count的修改是原子的。由于我们使用了synchronized块,因此可以保证在每个线程执行完毕后,count的值都会自增1,从而保证了有序性。

上一篇下一篇

猜你喜欢

热点阅读