线程小白的学习笔记(二)

本篇为 Java编程的逻辑 的并发内容的学习笔记的第二篇

共享内存以及带来的问题

不同线程之间可以共享内存,操作相同的变量,但是可能会发现一些意料之外的问题,其中有名就是竞态问题内存可见性问题

竞态问题

所谓的竞态是指:当多个线程访问和操作同一个对象时,最终执行结果和执行时序有关,也就是执行结果可能正确也可能不正确。

我们可以看下面的例子:

public class CounterThreadDemo {

    private static int mCounter = 0;

    public static class CounterThread extends Thread {

        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                mCounter++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<Thread> mThreadList = new ArrayList<>(1000);
        for (int i = 0; i < 1000; i++) {
            CounterThread thread = new CounterThread();
            mThreadList.add(thread);
            thread.start();
        }
        for (Thread thread : mThreadList) {
            thread.join();
        }
        System.out.println("Counter is " + mCounter);
    }

}

上面的代码还是很好理解的,创建 1000 个线程操作全局变量 mCounter ,每个线程都对 mCounter 循环叠加了 1000 次,main 线程等待全部线程执行完毕后输出 mCounter 的值。

期待结果是 100 万,但是实际执行结果都是 90 多万居多。为什么呢?因为 mCounter++; 并不是原子操作,它实际上分为 3 个步骤的:

  1. 取到当前的 mCounter 的值
  2. 在当前值上面加 1
  3. 将新的值赋予给 mCounter

在这个情况下,会发生多个线程同时执行第一步,取到到相同的 mCounter 值,比方说两个线程同时取到了 100 ,那么最终在两个线程里面的赋值就变成了 101 ,所以就出现了最终结果可能不符合我们期待的情况。

内存可见性

多个线程可以共享内存和操作相同的变量,但是一个线程对一个共享变量的修改,另一个线程不一定马上就能看到,甚至一直都看不到,我们来看下面的例子:

public class VisibilityThread extends Thread {

    private static boolean mShutdown = false;

    @Override
    public void run() {
        while (!mShutdown){
        }
        System.out.println("exit shutdown");
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityThread thread = new VisibilityThread();
        thread.start();
        Thread.sleep(1000);
        mShutdown = true;
        System.out.println("exit main");
    }
}

上面代码是 main 线程 和 Visibility 线程共享了 mShutdown 变量,然后在 main 线程里面将这个变量设置为 true,让 Visibility 线程 顺利结束。

但是实际执行时,我们往往只能看到 main 线程已经结束了,但是 Visibility 线程一直没有结束,那怕 mShutdown 变量已经给更改为 true 了。

这个就是内存可见性问题

在计算机系统里面,数据可以保存在内存和各种缓存之中,当一个线程访问一个数据时,它可能是在缓存中读取,也可能是在内存中读取,同理,当一个线程修改或写入数据时,可能是先存到缓存里面,晚些同步到内存中,也可能是直接写在内存里面。

因此, A 线程对内存上面的数据进行修改, B 线程可能没有在运行时看到,实在太正常了。B 线程它不一定会在内存里面读取数据,再说 A 线程的修改也未必及时同步到内存里面。

轻量锁 volatile

前面说过,操作系统可能把数据保存到内存和各种缓存之中,导致了其他线程无法及时获取其真正的值而出现了内存可见性问题

那么换言之,只要数据的变化在线程之间是内存可见的,那么这个问题也就不存在了。

于是,Java 很贴心地给我们提供了 volatile 关键字:

将数据声明为 volatile 之后,所有对数据的写操作会立即写入主内存中,同样,所有对数据的读操作都会从主内存中读取数据。

也就是说,如果 A 线程对声明了volatile 关键字的数据进行了读写操作,B 线程在对这个数据进行读写操作时,也会第一时间知道,这就确保了线程之间的内存可见性了 。

通过 volatile ,可以轻松解决上面的 Visibility 线程和 main 线程之间的问题:

 private static volatile boolean mShutdown = false;

加上关键字后,就不会遇到 Visibility 线程一直没有结束的情况了。

exit main
exit shutdown

volatile 的缺陷

volatile 只能确保数据的内存可见性,它无法确保操作的原子性

就拿上面的竞态问题来说,volatile 对此是无能为力的。为什么呢?

正如上面所说的,mCounter++; 实际上分为 3 个步骤的,那么就极可能出现:

多个线程同时会读取 volatile 变量的相同值,然后产生新值并写入主内存,这样将会覆盖互相的值,最终就是多个线程对过期的 mCounter 的值进行相加或赋值,得出的结果还是不符合我们的期待。

因此,volatile适用原子性操作的场景,专门解决内存可见性的问题 ,因此 volatile 也被人称呼为 轻量锁

理解synchronized

要解决多线程的竞态问题,可以使用 synchronized 关键字,它具备确保原子性操作内存可见性的作用,可以说,这是个 重量级的锁

我们先来看它是怎么解决问题的:

public class CounterThreadDemo {

    private static int mCounter = 0;

    private static synchronized void addCounter(){
        mCounter++;
    }

    public static class CounterThread extends Thread {

        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                addCounter();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<Thread> mThreadList = new ArrayList<>(1000);
        for (int i = 0; i < 1000; i++) {
            CounterThread thread = new CounterThread();
            mThreadList.add(thread);
            thread.start();
        }
        for (Thread thread : mThreadList) {
            thread.join();
        }
        System.out.println("Counter is " + mCounter);
    }
}

解决方法也很简单,把递增的操作放在 addCounter 方法里面,然后使用 synchronized 关键字进行修饰,之后多线程并发操作,也不会出现问题。因为 synchronized 关键字把方法内的代码块变成了原子性操作

怎么确保代码块变成了原子性操作呢?它是通过确保同一个对象的方法调用在同一个时间内只有一个线程执行来实现原子性的,也就是说 synchronized 实际上保护的还是当前的实例对象,也就是 thisthis 对象有一个锁和等待队列,而锁只有给一个线程持有,其他线程想获得锁都需要等待。

synchronized 执行过程简述:

  1. 尝试获取锁,如果获取成功则执行下一步,不行线程就进入阻塞状态并等待唤醒
  2. 执行锁里面的实例方法体的代码
  3. 释放锁,如果有线程在等待唤醒,则随机唤醒一个来获取锁并执行代码块

这里还需要强调的是,synchronized 保护的是一个对象,而不是代码,只要访问的是同一个对象的 synchronized 方法,即使是不同的代码,也会被同步顺序执行

public class CounterThreadDemo {

    private static int mCounter = 0;

    private static synchronized void addCounter() {
        System.out.println("run addCounter");
        mCounter++;
    }

    private static synchronized void minusCounter() {
        System.out.println("run minusCounter");
        mCounter--;
    }

    public static class CounterThread extends Thread {

        @Override
        public void run() {
            addCounter();
        }
    }

    public static class MinusThread extends Thread {

        @Override
        public void run() {
            minusCounter();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<Thread> mThreadList = new ArrayList<>(1000);
        for (int i = 0; i < 1000; i++) {
            CounterThread thread = new CounterThread();
            MinusThread minusThread = new MinusThread();
            mThreadList.add(thread);
            mThreadList.add(minusThread);
            thread.start();
            minusThread.start();
        }
        for (Thread thread : mThreadList) {
            thread.join();
        }
        System.out.println("Counter is " + mCounter);
    }
}

我们这次创建两个线程对象类,一个递增,一个递减,并发之后的打印如下:

run addCounter
run minusCounter
..........
run addCounter
run minusCounter

一直都是这样子同步顺序输出的,不会出现异步的情况,但是如果把 minusCounter 方法的 synchronized 关键字去掉,那么结果就肯定不是我们所期待的了,因为 synchronized 无法让非 synchronized 修饰的方法给同步顺序执行。

代码块

synchronized 除了可以修饰方法,还可以用于包装代码块,这也叫做同步代码块,如下所示:

public class Counter {
        private int counter;

        public  void addCounter() {
            synchronized (this) {
                counter++;
            }
        }
}

synchronized 括号里面的就是需要保护的对象。也就是 this ,{ } 里面就是同步执行的代码,我们可以用上面的 Counter 类来修改下一开始的竞态问题的代码,通过运行结果可以得知 synchronized 不管是修饰方法还是包装代码块,都是具备确保原子性操作内存可见性的作用:

public class CounterThreadDemo {

    public static class CounterThread extends Thread {

        Counter mCounter;

        CounterThread(Counter counter) {
            this.mCounter = counter;
        }

        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                mCounter.addCounter();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        List<Thread> mThreadList = new ArrayList<>(1000);
        Counter counter = new Counter();
        for (int i = 0; i < 1000; i++) {
            CounterThread thread = new CounterThread(counter);
            mThreadList.add(thread);
            thread.start();
        }
        for (Thread thread : mThreadList) {
            thread.join();
        }
        System.out.println("Counter is " + counter.getCounter());
    }
}
Counter is 1000000

synchronized 同步的对象可以是任意对象,因为任意对象都有一个锁和等待队列,也就是说任意对象都可以成为同步锁的对象。因此,Counter 类的代码还可以这么修改:

public class Counter {
       private int counter;
       private final Object lock = new Object();

       public void addCounter() {
            synchronized (lock) {
                counter++;
            }
        }

       public int getCounter() {
            return counter;
        }
}

死锁

使用 synchronized 时需要注意到这个死锁的问题。所谓死锁就是:

有 A 线程 和 B线程 ,A 线程 持有 锁A , 在等待 锁B ,而 B 线程 持有 锁B ,在等待 锁A , A 线程 和 B线程 陷入了互相等待之中,最后谁都无法执行。

这也正如下面代码所示:

public class DeadLockDemo {

    private final static Object lockA = new Object();
    private final static Object lockB = new Object();

    private static void startThreadA() {
        Thread thread = new Thread() {
            @Override
            public void run() {
                synchronized (lockA) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lockB) {

                    }
                }
            }
        };
        thread.start();
    }

    private static void startThreadB() {
        Thread thread = new Thread() {
            @Override
            public void run() {
                synchronized (lockB) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lockA) {

                    }
                }
            }
        };
        thread.start();
    }

    public static void main(String[] args) {
        startThreadA();
        startThreadB();
    }
}

运行后程序就会陷入互相等待之中,我们可以通过 Java 自带的 jstack 检测运行中的程序是否存在死锁;

这里写图片描述

jstack 检测方法很简单,首先知道运行的 Java 程序的 pid:

这里写图片描述

那么接下来就是在 cmd 里面跳转到 jstack 所在路径,输入下面的命令:

jstack 9272

就可以看到下面的检测报告:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x0000000017c4d4b8 (object 0x00000000d5a85358, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x0000000017c4ac28 (object 0x00000000d5a85368, a java.lang.Object),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at one.share.DeadLockDemo$2.run(DeadLockDemo.java:39)
        - waiting to lock <0x00000000d5a85358> (a java.lang.Object)
        - locked <0x00000000d5a85368> (a java.lang.Object)
"Thread-0":
        at one.share.DeadLockDemo$1.run(DeadLockDemo.java:20)
        - waiting to lock <0x00000000d5a85368> (a java.lang.Object)
        - locked <0x00000000d5a85358> (a java.lang.Object)

Found 1 deadlock.

可以看到 jstack 检测到死锁并告诉你所在的位置了,方便你去解决。

不过我们更应该是在平时写代码时注意回避这个问题,应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所以代码都应该按照相同的顺序去申请锁。

对于上面的例子,其实是可以约定先申请 锁lockA ,再去申请 锁lockB 。

一些注意事项

加了 synchronized 之后,所有方法都变成了原子操作,这样子是不是绝对安全了呢?然而并不是,至少下面的几种情况还是需要注意的;

复合操作

复合操作,除了上面提到的 mCounter++ ,还有先检查再更新等之类的情况,我们可以简单认为实际执行代码超过 1 行的都是复合操作了。

我们先来看下面这段代码:

public class SynMap<K, V> {

    private Map<K, V> map;

    public SynMap(Map<K, V> map) {
        this.map = Collections.synchronizedMap(map);
    }

    public V put(K key, V value) {
        return map.put(key, value);
    }

    public V putIfAbsent(K key, V value) {
        V old = map.get(key);
        if (old != null) {
            return old;
        }
        return map.put(key, value);
    }
}

SynMap 是一个装饰者类, 接收一个 Map 对象并转为同步容器 synchronizedMap ,并写了一个 putIfAbsent 方法,在 Map 里面有重复的键值时就不进行添加了。

上面的 put 是安全的,因为synchronizedMap 里面的方法都是经过 synchronized 修饰的,但是 putIfAbsent 是安全的吗?

显然不是了,在多线程并发的情况下,极有可能同时多个线程通过了非空判断,修改了已存在的键值对应的 key值 ,这明显了不是我们的期待。

伪同步

那么给该方法加上synchronized 修饰,可以实现安全吗?

public synchronized V putIfAbsent(K key, V value) {
        V old = map.get(key);
        if (old != null) {
            return old;
        }
        return map.put(key, value);
}

答案也是不行的,为什么呢?因为 synchronized 需要锁住的对象搞错了!

synchronized 修饰方法的话锁住的是 this ,也就是 SynMap ,但是 SynMap 里面其他方法使用的对象锁是 synchronizedMap

这两者是不同的对象,而 synchronized 是通过确保同一个对象的方法调用在同一个时间内只有一个线程执行来实现原子性的,所以要解决这个问题,就必须所有的方法都使用同一把对象锁。

因此,putIfAbsent 应该这么修改:

public V putIfAbsent(K key, V value) {
        synchronized(map){
            V old = map.get(key);
            if (old != null) {
                return old;
            }
            return map.put(key, value);
        }
}

迭代问题

Java 提供的同步容器,在单个数据操作是安全的,但是在迭代里面呢?

我们来看个例子:

public class ListThreadDemo {

    private static void startSleepThread(final List<String> list) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    list.add("item is " + i);
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        thread.start();
    }

    private static void startPrintlnThread(final List<String> list) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    for (String item : list) {
                        System.out.println(item);
                    }
                }
            }
        });
        thread.start();
    }

    public static void main(String[] args) {
        List<String> list = Collections.synchronizedList(new ArrayList<String>());
        startSleepThread(list);
        startPrintlnThread(list);
    }
}

这里创建了一个同步容器 List ,一个线程修改 List ,另一个遍历,如果对 List 熟悉的话,应该都知道这里肯定奔溃,因为 List 在遍历的同时发生了结构性变化就会抛出异常:

Exception in thread "Thread-1" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    at java.util.ArrayList$Itr.next(ArrayList.java:851)
    at one.share.ListThreadDemo$2.run(ListThreadDemo.java:31)
    at java.lang.Thread.run(Thread.java:748)

要避免这个问题发生,就需要我们的遍历时把这个 list 对象锁起来,让另一个线程暂时无法对其进行操作,从而回避了这个异常,这里再次强调 synchronized 锁住的是对象,而不是代码!

private static void startPrintlnThread(final List<String> list) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (list) {
                        for (String item : list) {
                            System.out.println(item);
                        }
                    }
                }
            }
        });
        thread.start();
    }

猜你喜欢

转载自blog.csdn.net/f409031mn/article/details/80717911