Java 并发编程是为了让程序更快的执行,编写并发程序需要理解计算机的底层知识,对细节的理解、锁机制的掌握要求较高,只有清楚的认识并理解并发编程的实质,才能够写出安全、可靠的多线程并发程序。要想使并发程序能够正确的执行,必须要满足三条原则:原子性、可见性、有序性。

1. 创建线程的方式

  • 继承 Thread 类
    • 重写 Thread 类中的 run() 方法,调用 start() 方法执行线程。
  • 实现 Runnable 接口
    • 通过实例化一个线程对象,调用 start() 方法。

以下是两种不同的实现方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 继承 Thread 类
class MultiThreadDemo extends Thread {

    public void run() {
        System.out.println("Thread " + Thread.currentThread().getId() + " is running.");
    }
}

public class MultiThead {
    public static void main(String[] args) {
        int n = 10;
        for (int i = 0; i < n; i++) {
            MultiThreadDemo o = new MultiThreadDemo();
            o.start();
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 实现 Runnable 接口
class MultiThreadDemo implements Runnable {

    public void run() {
        System.out.println("Thread " + Thread.currentThread().getId() + " is running.");
    }
}

public class MultiThead {
    public static void main(String[] args) {
        int n = 10;
        for (int i = 0; i < n; i++) {
            Thread t = new Thread(new MultiThreadDemo());
            t.start();
        }
    }
}

当然也可以放在一个类中,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * 创建线程的两种方式
 * 程序的输出结果与代码的执行顺序或调用顺序无关
 */
public class TestThread {

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                System.out.println("Thread.");
            }
        }.start();

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Runnable.");
            }
        });
        thread.start();

        System.out.println("main.");
    }
}

输出结果如下:

1
2
3
main.
Thread.
Runnable.

2. 线程的上下文切换

CPU 通过给每个线程分配时间片来实现多线程的执行,但在线程切换之前,会保存上一个任务的状态,以便下次切换回该任务时,能搞再次加载该任务,所以从保存状态到加载状态的过程就是一次线程的上下文切换。

下面演示了多线程与串行执行实例,在执行次数较少时,由于并发操作执行过程中会使线程之间进行频繁的上下文切换,降低了执行效率,反而比串行执行的速度要慢。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package Chapter_01;

public class ConcurrencyTest {
    private static final long count = 2000000001;

    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 (int 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("concurrency: " + 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);
    }
}

如何减少上下文的切换?

  • 无锁并发编程。将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据,从而避免使用锁。
  • CAS(CompareAndSwap)算法。使用 Atomic 包中的 CAS 算法在不需要加锁的情况下更新数据。
  • 使用最少线程。即避免创建不需要的线程。
  • 协程。在单任务里实现多任务调度的同时实现多任务的切换。

3. 死锁

避免死锁:

  • 避免一个线程同时获得多个锁;
  • 避免一个线程在锁内同时占用多个资源;
  • 使用 lock.tryLock(timeout) 定时锁;
  • 在对数据库进行加锁或解锁时,应在同一个数据库连接里。

死锁实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package Chapter_01;

public class DeadLockTest {

    private static String A = "A";
    private static String B = "B";

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

    private void deadLock() {
        Thread t1 = 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 t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (B) {
                    synchronized (A) {
                        System.out.println("2");
                    }
                }
            }
        });

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

4. volatile

volatile 是轻量级的 synchronized,其在多处理器中保证了共享变量可见性,即当一个线程修改一个共享变量时,另一个线程会读到这个修改的值。被 volatile 所修饰的变量比 synchronized 所修饰的在使用上和执行的成本上更低,因为 volatile 不会引起线程之前的上下文切换和调度。

在 Java 中,允许多个线程访问共享变量,但某线程在访问前需要通过排他锁单独的获得该变量。

被 volatole 修饰的共享变量进行写操作时,会在汇编指令之前添加一个 lock 指令。有如下两个作用:

  • Lock 前缀指令会将当前的处理器缓存的数据写回到系统内存;
  • 一个处理器的缓存写回到内存会导致其他处理器的缓存无效。

5. synchronized

JDK1.6 之前称 synchronized 为重量级锁,但 JDK1.6 之后对其进行了优化,为了减少获得锁和释放锁带来的性能消耗而引入偏向锁轻量级锁

synchronized 实现同步的方式:

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

synchronized 用的锁是存储在 Java 对象头里面的。如果对象是数组类型,则使用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则使用 2 个字宽存储对象头。

Java 对象头里的 Mark Word 里默认存储对象的HashCode分代年龄锁标记位

锁的状态无锁、偏向锁、轻量级锁、重量级锁

锁只能升级而不能降级,目的是为了提高获得锁和释放锁的效率。

偏向锁:为了让线程获得锁的代价更低而引入了偏向锁,当一个线程访问同步块时并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁。只需要测试一下对象头的 Mark Word 里是否还存着指向当前线程的偏向锁,如下:

  • 若成功,则表示已经获得了锁;
  • 若失败,则还需测试偏向锁的标志位是否是 1:
    • 若没有设置,则使用 CAS 竞争锁;
    • 若已设置,则使用 CAS 将对象头的偏向锁指向当前线程。

撤销偏向锁时,只有当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

轻量级锁:线程在执行同步之前,JVM 会在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中(Displaced Mark Word)。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。

  • 若成功,则当前线程获得锁;
  • 若失败,则其他线程竞争锁,当前线程使用自旋的方式获取锁。

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

轻量级锁在进行解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头:

  • 若成功,则表示没有发生竞争;
  • 若失败,则表示当前存在竞争,锁就会膨胀成重量级锁。

优缺点:

锁类型 优点 缺点 使用场景
偏向锁 加锁和解锁不需要额外的消耗 如果线程之间存在锁竞争,则会出现锁撤销的消耗 只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,则会发生自旋 追求响应时间,同步块执行很快的场景
重量级锁 线程竞争不会自旋 线程阻塞,响应时间慢 追求吞吐量,同步块执行速度较长

6. 原子操作

原子操作(atomic operation):不可被中断的一个或一系列操作。

CAS(Compare And Swap):比较并交换,需要的参数有:内存地址、旧值(期望操作前的值)、即将更新的值。在操作期间先比较旧值有没有发生变化,如果没有变化,才交换成新值,否则发生了变化则不交换。

处理器如何保证原子操作

  • 对总线加锁
    • 如果多个处理器同时对共享变量进行读改写操作(如 i++),则这样的读改写操作就不是原子的。
    • 原因是:多个处理器同时从各自的缓存中读取变量,分别进行加 1 操作,然后又分别写回了内存中。
    • 解决方法:使用总线锁,当一个处理器在总线是输出 LOCK # 信号时,则其他处理器的请求会被阻塞,那么当前处理器就可以独占共享内存了。
    • 缺点:在锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定开销较大。
  • 对缓存加锁
    • 内存区域如果被缓存在缓存行中,并在 LOCK 操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上发送 LOCK # 信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。

缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器同时回写已被锁定的缓存行中的数据时,会使缓存无效。

不会使用缓存锁定的情况:

  • 当操作的数据不能被缓存在处理器内部时,或操作的数据跨越多个缓存行时,此时会使用总线锁定。
  • 某些处理器不支持缓存锁定。

使用自旋 CAS 实现的基本思路是循环进行 CAS 操作直到成功为止。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class CASTest {
    private AtomicInteger atomicInteger = new AtomicInteger(0);

    // 使用 CAS 实现线程安全的计数器
    private void safeCount() {
        for (; ; ) {
            int i = atomicInteger.get();
            boolean suc = atomicInteger.compareAndSet(i, ++i);
            if(suc) {
                break;
            }
        }
    }
    
    // 非线程安全计数器
    private void unsafeCount() {
        i++;
    }
}

在调用compareAndSet()方法时,其实是调用了compareAndSwapInt()方法:

1
2
3
public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

使用自旋 CAS 存在的问题:

  • ABA 问题:
    • 如果一个值原来是 A,变成了 B,结果又变成了 A,在使用 CAS 操作该值的时候会检查它的值没有发生变化,但实际上是发生变化了。
    • 解决思路是:使用版本号。例如 A->B->A 变成 1A->2B->3A。
  • 循环时间长开销大:
    • 自旋 CAS 会给 CPU 带来长时间的开销。如果 JVM 支持 pause 指令,则可以提升效率。
    • pause 的作用:可以延迟流水线执行指令。还可以避免在退出循环的时候因内存顺序冲突而引起的 CPU 流水线被清空,从而提高 CPU 的执行效率。
  • 只能保证一个共享变量的原子操作:
    • 循环 CAS 无法保证多个共享变量的操作。
    • 可将多个共享变量合并成一个共享变量。

7. 并发编程的关键

通信:指线程之间以何种机制来进行信息的交换。

同步:程序中用于控制不同线程间操作发生相对顺序的机制。

线程通信机制:共享内存消息传递

  • 线程通信
    • 共享内存
    • 线程之间共享程序的公共状态,通过读/写内存中的公共状态进行隐式通信。(Java 中的线程采用共享内存的方式进行通信。)
    • 消息传递
    • 线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
  • 线程同步
    • 共享内存
    • 同步是显式进行的,必须显式的指定某个方法或某个代码段需要在线程之间互斥执行。
    • 消息传递
    • 消息发送必须在消息接收之前,该同步是隐式的。

每个线程对应一个本地内存,假如线程 A 和线程 B 进行通信,则线程 A 先更新某个值临时存放在自己的本地内存 A 中。当进行通信时,线程 A 会把自己本地内存中修改后的值刷新到主内存中。随后,线程 B 到主内存中取读取线程 A 更新后的值,此时线程 B 的本地内存的值也就就被更新了。

8. 等待/通知机制

下面是不好的实现方式,难以保证实时性、难以降低开销:

1
2
3
4
while (value != desire) {
    Thread.sleep(1000);
}
doSomething();

可使用 java.lang.Obejct 类中的 wait()、notify()、notifyAll()、方法解决上面的问题。

注意:调用 wait 方法的线程会进入 WAITING 状态,只有等待另外线程的通知或被中断才会返回。调用该方法后,会释放对象的锁。

等待/通知机制是指:一个线程 A 调用了对象 O 的 wait() 方法进入了等待状态,而另一个线程 B 调用了对象 O 的 notify() 或 notifyAll() 方法,线程 A 收到通知后从对象 O 的 wait() 方法返回,进而执行后序的操作。

参考

Multithreading in Java

Java 并发编程的艺术