Java 并发基础学习

版权声明:本文为博主原创文章,转载请征得博主同意并注明出处。 https://blog.csdn.net/qq_17250009/article/details/79012528

本文已授权微信公众号《鸿洋》原创首发,转载请务必注明出处。

三个基础概念

  1. 原子性。一个操作或者一系列骚操作,要么全部执行要么全部不执行。数据库中的“事物”就是个典型的院子操作。
  2. 可见性。当一个线程修改了共享属性的值,其它线程能立刻看到共享属性值的更改。举个例子:由于JMM(Java Memory Model)分为主存和工作内存,共享属性的修改过程为从主存中读取并复制到工作内存中,在工作内存中修改完成之后,再刷新主存中的值。如果线程A在工作内存中修改完成但还没有刷新主存中的值,线程B看到的值还是旧值。这样可见性就没法保证。
  3. 有序性。程序的运行顺序似乎和我们编写逻辑的顺序是一致的,但计算机在实际执行中却并不一定。为了提高性能,编译器和处理器都会对代码进行重新排序。但是有个前提,重新排序的结果要和单线程执行程序顺序一致。
int a = 0;      // 语句A
int b = 1;      // 语句B
int c = a + b;  // 语句C

由于语句A和语句B没有数据依赖。重排序后,语句A和语句B,在计算机中的执行顺序可能是AB也可能是BA,但AB都与C有数据依赖,所以AB都在C前面执行。

Java中控制并发的几种方式

  1. volatile
  2. synchronized
  3. CAS/AQS
  4. concurrent并发包

volatile

volatile用来保证可见性和有序性,不保证原子性。

volatile保证的可见性

volatile修饰的属性保证每次读取都能读到最新的值,可是并不会更新已经读了的值,它也无法更新已经读了的值。线程A在工作内存中修改共享属性值会立即刷新到主存,线程B/C/D每次通过读写栅栏来达到类似于直接从主存中读取属性值,注意,是类似,网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为。读写栅栏是一条CPU指令,插入一个读写栅栏, 相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性)。读写栅栏另一个作用是强制更新一次不同CPU的缓存。例如,一个写栅栏会 把这个栅栏前写入的数据刷新到缓存,以此保证可见性。

valatile保证的有序性

当对volatile修饰的属性进行读/写操作时,其前面的代码必须已经执行完成且结果对后续的操作可见。在重排序时,以volatile修饰属性的读/写操作代码行为分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。由此保证有序性.

{   // 线程A
    bean = new Bean();     // 语句A
    inited = true;         // 语句B
}

{   // 线程B
    if(inited){            // 语句C
        bean.getAge();     // 语句D
    }
}

在线程A中语句AB没有任何数据依赖,所以可能会被重排序成先执行语句B,后执行语句A。假设线程A先执行完语句B之后(这时还没有执行语句A)被挂起,CPU转而执行线程B,由于bean对象没有初始化,所以在执行到语句D就会出错。如果inited属性用volatile修饰,就不会发生这种错误的重排序。

volatile不保证原子性

由于volatile保证可见性和有序性,被volatile修饰的共享属性一般并发读/写没有问题,可以看做是一种轻量级的synchronized的实现。但有些情况比较特殊,比如i++自增。举个栗子。

volatile int a = 0; // 语句A
a++;                // 语句B

a++。其实包含了两步操作。读取a, 执行a+1并将a+1结果赋值给a。假设线程A执行完第一步之后被挂起。线程B执行了a++。那么主存中a的值为1。但是线程A的工作内存中还是0,由于线程A在之前就已经读取了a的值,执行a++之后再次将a的值刷新到主存,也就是说,a++执行了两次,但两次都是从0变为1。所以a的值最终为1。这里有个槽点,之前说volatile修饰的属性,每次读取都是最新的值,这里线程B执行a++之后,线程A里怎么还是0?应该是1啊!我觉得这是volatile一个比较鸡肋的地方,volatile修饰的属性,如果在修改之前已经读取了值,那么修改之后,无法改变已经复制到工作内存的值。体会一下~

synchronized

synchronized保证原子性、可见性和有序性。用来修饰方法或者代码块。下面是synchronized的一些规则。

  1. 根据锁对象的不同,一把锁同时最多只能被一个线程持有。
  2. 如果目标锁已经被当前线程持有,其它线程只能阻塞等待其它线程释放目标锁。
  3. 如果当前线程已经持有了目标锁,其他线程仍然可以调用目标类中没有被synchronized修饰的方法。

以上规则对下文的Lock同样适用。

锁对象举例

synchronized修饰方法或者synchronized(this)


public class Test {

    static SyncTest test1 = new SyncTest();
    static SyncTest test2 = new SyncTest();

    public static void main(String[] args) {

        new Thread(new Runnable() {
            @Override
            public void run() {
                test1.syncTwo();
            }
        }).start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        test1.syncOne();
    }
}

public class SyncTest {

    public synchronized void syncOne(){
        System.out.println("ThreadId : " + Thread.currentThread().getId());
        System.out.println("one");
    }

    public void syncTwo(){
        synchronized (this) {
            int a =0;
            while(true){
                a++;
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("ThreadId : " + Thread.currentThread().getId());
                if(a == 5) break;
            }
            System.out.println("two");
        }
    }
}

控制台输出:
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
two
ThreadId : 1
one

为了先调用test1.syncTwo(),这里先将主线程暂停1s。从控制台输出可以发现,子线程确实被阻塞了,从而说明synchronized修饰方法或者synchronized(this),获得的都是实例对象的锁。

如果将test1.syncOne()换成test2.syncOne();那么主线程就不会阻塞了。控制台输出为:

ThreadId : 1
one
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
ThreadId : 10
two

synchronized(xxx.class)/synchronized static

public class SyncTest {

    public synchronized static void syncOne() {
        System.out.println("ThreadId : " + Thread.currentThread().getId());
        System.out.println("one");
    }

    public void syncTwo() {
        synchronized (SyncTest.class) {
            int a = 0;
            while (true) {
                a++;
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("ThreadId : " + Thread.currentThread().getId());
                if (a == 5)
                    break;
            }
            System.out.println("two");
        }
    }
}

如果将SyncTest中的synchronized(this)换成synchronized(SyncTest.class)/synchronized static。那么无论调用test1.syncOne()还是test2.syncOne(),主线程都会阻塞。synchronized(SyncTest.class)/synchronized static这种写法,保证对TestSync类的访问,同一时刻只能有一个线程持有锁。

synchronized实现的是阻塞型并发,synchronized修饰的范围越大,瓶颈越高。为了解决这种问题,由此又有减小锁范围、减小锁粒度和锁分段之说。鉴于篇幅,详细还请自行查看。

CAS

CAS(compare and swap),即比较并交换。synchronized锁住的代码块,同一时刻只能由一个线程访问。属于悲观锁。相对于这种需要挂起线程的悲观锁,还一种由CAS实现的乐观锁。CAS包含三个部分:

  1. 内存地址A
  2. 预期旧值B
  3. 预期新值C

在进行CAS操作时,首先比较A和B,如果相等,则更新A中的值为C并返回true。否则,返回false。通常CAS伴随着死循环,以不断尝试更新的方式实现并发。伪代码如下:

public boolean compareAndSwap(long memoryA, int oldB, int newC){
    if(memoryA.get() == oldB){
        memoryA.set(newC);
        return true;
    }
    return false;
}

相对于synchronized省去了挂起线程、恢复线程的开销,但是如果迟迟得不到更新,死循环对CPU资源也是一种浪费。

使用CAS有个“先检查后执行”的操作,而这种操作在Java中是典型的不安全的操作,所以CAS在实际中是由C++通过调用CPU指令实现的。CAS在Java中的体现为Unsafe类,Unsafe类会通过C++直接获取到属性的内存地址,接下来CAS由C++的Atomic::cmpxchg方法实现。这个方法会在CPU指令中添加lock指令,而带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存。具体可参考这篇文章。我觉得CAS相对于synchronize,本质上也是一种阻塞的实现。只是阻塞的粒度(CPU指令级别)更小。

AQS

AQS(AbstractQueuedSynchronizer)中维护着一个volatile修饰的属性“state”和一个双向链表,通过使用Unsafe中CAS对“state”属性的一些列骚操作(实际就是把state当做标志位)实现独占锁和共享锁,独占锁和共享锁又分为公平锁和非公平锁。

  1. 独占锁:同一时刻只有一个线程持有同一锁,其余线程在链表中排队。
  2. 共享锁:同一时刻可以多个线程持有同一锁。
  3. 公平锁:锁被线程持有后,其余线程排队执行。锁按照FIFO放入链表。
  4. 非公平锁:锁被线程持有后,其余线程排队执行。锁按照FIFO放入链表。但是在刚释放锁的之后,如果有新线程竞争锁,那么新线程将和链表中下个即将被唤醒的线程竞争锁。

关于AQS,我找到两篇比较好的文章,这里就不赘述了。想深入了解的可以看下源码。

深度解析Java 8:JDK1.8 AbstractQueuedSynchronizer的实现分析(上)

深度解析Java 8:AbstractQueuedSynchronizer的实现分析(下)

concurrent

JDK在java/util/concurrent提供了很多常用的并发类及并发容器类。并发类基本是通过lock(CAS/AQS)实现,并发容器基本是通过synchronize和lock(CAS/AQS)实现的。这是一篇基础,有机会再慢慢补齐这些。作者水平有限,如有错误,接受有偿指正~

展开阅读全文

没有更多推荐了,返回首页