Java并发编程的艺术学习笔记

232 阅读6分钟

第一章 并发编程的挑战

上下文切换

cpu通过给每个线程分配cpu时间片来实现在单核处理器上支持多线程,时间片是cpu分配给多个线程的时间,时间片时间非常短,所以cpu通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒

并发执行不一定比串行快,如下代码:

public class ConcurrencyTest {
    private static final long count = 10000L;

    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
    }

    private static void concurrency() throws InterruptedException {
        long start = System.currentTimeMillis();

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                int a = 0;
                for (long i = 0; i < count; i++) {
                    a += 5;
                }
            }
        });

        thread.start();
        int b = 0;
        for (long i = 0; i < count; i++) {
            b--;
        }
        thread.join();
        long time = System.currentTimeMillis() - start;
        System.out.println("currency:" + time + "ms,b=" + b);
    }


    private static void serial() {
        long start = System.currentTimeMillis();
        int a = 0;
        for (long i = 0; i < count; i++) {
            a += 5;
        }
        int b = 0;
        for (long i = 0; i < count; i++) {
            b--;
        }
        long time = System.currentTimeMillis() - start;
        System.out.println("serial:" + time + "ms,b=" + b);
    }
}

count为10000,运行结果:

currency:1ms,b=-10000
serial:1ms,b=-10000

count为100000000000,运行结果:

currency:25388ms,b=-1215752192
serial:99418ms,b=-1215752192

可见并发执行在一定的场景下不一定比串行快;

为什么并发执行的速度会比串行慢:线程有创建和上下文切换的开销

如何减少上下文切换

减少上下文切换的方法:无锁并发编程、cas算法、使用最少线程和使用协程

死锁

public class DeadLockDemo {
    private static String A = "A";
    private static String B = "B";

    public static void main(String[] args) {
        new DeadLockDemo().deadLock();
    }

    private void deadLock() {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (A) {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (B) {
                        System.out.println("1");
                    }
                }
            }
        });


        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (B) {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (A) {
                        System.out.println("2");
                    }
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

如何避免死锁:

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
  • 对于数据库锁,加锁和解锁必须在同一个数据库连接里,否则会出现解锁失败的情况

Java并发机制的底层实现原理

volatile的应用

volatile是轻量级的synchronized,在多处理器开发中保证来共享变量的“可见性”,它不会引起线程上下文的切换和调度

volatile的定义与实现原理

volatile是如何保证可见性的: Java代码如下:

instance = new Singleton();//instance是volatile变量

转变成汇编代码,如下: OxO1a3de1d:movb $Ox0,0X1104800(%esi);0X01a3de24:lock addl $0x0,(%esp);

lock前缀的指令在多核处理器下会引起两件事情:

  • 将当前处理器缓存行的数据写回到系统内存

lock一般不锁总线,而是锁缓存,缓存一致性机制会阻止同时修改两个以上处理器缓存的内存区域数据.

  • 这个写回内存的操作会使在其他cpu里缓存了该内存地址的数据无效.

synchronized 的实现原理与应用

利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁,具体的表现为一下三种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是synchronized括号里配置的对象

synchronized用的锁在存在Java对象头里的,如果是对象是数组类型,则虚拟机用3个字宽存储对象头,如果对象是非数组类型,则用2字宽存储对象头.

第3章 Java内存模型

3.4 volatile的内存语义

volatile变量自身具有以下特性:

  • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入.
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性

以下为volatile变量的试例代码:

class VolatileExample{
    int a = 0;
    volatile boolean flag = false;
    public void writer(){
        a = 1;              //1
        flag = true;        //2
    }
    public void reader(){
        if(flag){           //3
            int i = a;      //4
            .........
        }
    }
}

假设线程A执行writer()方法之后线程B执行reader()方法,根据happens-before规则,这个过程建立的happens-before关系可以分为3类:

  • 根据程序次序规则,1 happens-before 2;3 happens-before 4;
  • 根据volatile规则,2 happens - before 3;
  • 根据happens - before 的传递性规则,1 happens-before 4.

3.4.3 volatile写-读的内存语义

volatile写的内存语义如下:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中.

volatile读的内存语义如下:

当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效.线程接下来将主内存中读取共享变量.

volatile写和volatile读的内存语义总结:

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息.
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息.
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息.

3.4.4 volatile内存语义的实现

为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型.

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序.

以下是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障.
  • 在每个volatile写操作的后面插入一个StoreLoad屏障.
  • 在每个volatile读操作的后面插入一个LoadLoad屏障.
  • 在每个volatile读操作的后面插入一个LoadStore屏障.

volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性,在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势.

3.5 锁的内存语义

3.5.1 锁的释放-获取建立的happens-before关系

3.5.2 锁的释放和获取的内存语义

当线程释放时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中.

当线程获取锁时,JMM会把该线程对应的本地内存置为无效.

对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义.