《java并发编程实战》总结

Java
199
0
0
2024-02-27
标签   Java并发

第1章 简介

线程的优势:

①发挥多处理器的强大优势 ②建模的简单性 ③异步事件的简化处理④相应更灵敏的用户界面

线程带来的风险:

①安全性问②活跃性问题③性能问题

第2章 线程安全性

2.1什么是线程安全性

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

在线程安全的类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。

无状态对象一定是线程安全的。

2.2原子性

2.2.1竞态条件

竞态条件:当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果要取决于运气。最常见的静态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。

数据竞争:如果在访问非final类型的域时没有采用同步来进行协调,那么就会出现数据竞争。(JMM知识)

2.3加锁机制

2.3.1内置锁

Java提供了一种内置的锁机制来支持原子性synchronized

2.3.2重入

“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。

如下面代码所示:如果没有“重入”,则产生死锁。

public class Widget{
    public synchronized void doSomething(){
        System.out.println("Widget..doSomething");
    }
}
public class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        System.out.println("LoggingWidget..doSomething");
        super.doSomething();
    }
}

2.4用锁来保护状态

对于可能被多个线程同时访问的可变状态变量,在访问他的时候都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁来保护的。

每个共享和可变的变量都应该由一个锁来保护,从而使维护人员知道是哪一个锁。

对于每个包含多个变量的不变性条件,其中涉及的所所有变量都需要由一个锁来保护。

2.5活跃性与性能

通常,在简单性与性能之间存在相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能破坏安全性)。

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或者控制台I/O),一定不要持有锁。

第3章 对象的共享

我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字synchronized只能用于实现原子性或者确定“临界区(Critical Section)”。同步还有另一个重要的方面:内存可见性(Memory Visibility)。

3.1可见性(JMM知识)

如下面代码所示,明明flag已经改为true,为什么程序没有停止?因为主线程没有看到最新的flag的值

import java.util.concurrent.TimeUnit;

/**
 * @author CBeann
 * @create 2020-03-26 13:22
 */
public class NoVisibility {
    public static void main(String[] args) {
        ThreadDemo threadDemo = new ThreadDemo();
        new Thread(threadDemo).start();

        while (true) {
            if (threadDemo.isFlag()) {
                System.out.println("------");
                break;
            }
        }


    }


}

class ThreadDemo implements Runnable {

    private boolean flag = false;

    @Overridepublic void run() {
        try {
            TimeUnit.MILLISECONDS.sleep(200);
        } catch (Exception e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag=" + isFlag());
    }

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

解决办法

private volatile boolean flag = false;
3.1.3 加锁与可见性

加锁的含义不仅仅局限性互斥行为,还包括内存可见性。为了确保所有的线程都能见到共享变量的最新值,所有执行读操作或者写操作的线程必须在同一个锁上同步。

3.1.4 Volatile变量

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。

volatile变量的典型用法:

volatile Boolean  flag;
        ...
        while(!flag){
            doSomeThing();
        }

加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

3.2 发布和逸出

“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。

当某个不应该发布的对象被发布时,这种情况就被称为“逸出(Escape)”。

如下面的代码所示,demo中的list被发布,而且逸出。

import java.util.ArrayList;
import java.util.List;

/**
 * @author CBeann
 * @create 2020-02-20 2:49
 */
public class Start {

    public static void main(String[] args) {
        Demo demo = new Demo();
        //调用getList方法发布Demo对象中的list对象List<String> list = demo.getList();
        //该对象逸出,因为list对象已经逸出它所在的作用域(Demo的是私有变量域)
        list.add("main-thread-add");
        for (String s : demo.getList()) {
            System.out.println(s);
        }
    }
}

class Demo {
    private List<String> list = null;

    public Demo() {
        list = new ArrayList<>();
        list.add("cbeann");
    }

    public List<String> getList() {
        return list;
    }

    public void setList(List<String> list) {
        this.list = list;
    }
}

3.3 线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步,这种技术称为线程封闭(Thread Confinement),它是实现线程安全性最简单方式之一。

3.3.2栈封闭

栈封闭是线程封闭的一种特例。在栈封闭中,只能通过局部变量表才能访问对象。

例如下面代码中的demo对象是一个局部变量,只要不发布,其它线程都无法获得该对象的引用。

    public static void main(String[] args) {
        Demo demo = new Demo();
        }
3.3.3 ThreadLocal类

把对象存在threadLocal对象中能实现不共享。因为它的key是thread,所以其它线程取不到数据。

        ThreadLocal threadLocal = new ThreadLocal();
        try {
//        public void set(T value) {
//            Thread t = Thread.currentThread();
//            ThreadLocal.ThreadLocalMap map = getMap(t);
//            if (map != null)
//                map.set(this, value);
//            else
//                createMap(t, value);
//        }
            threadLocal.set(1);
            Object o = threadLocal.get();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            threadLocal.remove();//最后要删除,否则容易出现内存泄露
        }

3.4不变性

不可变的对象一定是线程安全的。

即使对象中所有的域都是final类型的,这个对象仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。

如下面代码所示,demo里的list用final修饰,但是仍然可以添加。

public class Start {

    public static void main(String[] args) {
        Demo demo = new Demo();
        demo.getList().add(1);
        for (Object o : demo.getList()) {
            System.out.println(o);
        }
    }
}

class Demo {
    private final List list = new ArrayList();
    public List getList() {
        return list;
    }
}

3.5 安全发布

3.5.6安全的共享对象

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。

只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。

线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。

保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及发布的并且由某个特定的锁保护的对象。

第4章 对象的组合

对象的组合

在设计线程安全的类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量。
  • 找出约束状态变量的不变性条件。
  • 建立对象状态的并发访问管理策略。

线程安全的类组合的类不一定是线程安全的

//线程安全
class Demo {
    private Map map = new ConcurrentHashMap();
    public void put(String key, Object val) {
        map.put(key, val);
    }
}
//线程不安全
class Demo {
    private Map map = new ConcurrentHashMap();
    private Map map2 = new ConcurrentHashMap();
    public void put(String key, Object val) {
        if (map.containsKey(key)){
            map2.put(key, val);
        }
    }
}

本章疑问

为什么书中说下面的一个线程不安全,一个线程安全?

课本提示:线程不安全的代码中说list保护的锁反正不是ListHelper的锁,大致意思是锁不同,反正我没看懂,看懂的可以在下面留言。

//线程不安全
class ListHelper<E> {
    public List<E> list = Collections.singletonList(new ArrayList<>());

    public synchronized boolean putIfAbsent(E e) {
        boolean absent = !list.contains(e);
        if (absent) list.add(e);
        return absent;
    }
}
//线程安全
class ImprovedList<T> implements List<T> {

    private final List<T> list;

    public ImprovedList(List<T> list) {
        this.list = list;
    }

    public synchronized boolean putIfAbsent(T x) {
        boolean contains = list.contains(x);
        if (contains) list.add(x);
        return !contains;
    }

    //还要实现size,isempty等方法
}

第5章 基础构建模块

5.1同步类容器

5.1.3隐藏迭代器

虽然加锁可以防止迭代器抛出ConcurrentModificationException,但是你必须要记住在所有对共享容器进行迭代的地方都需要加速。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来。如下面代码所示。编辑器将字符串的连接操作转换为调用StringBuilder.append(Object),而这个方法又会调用容器的toString方法,标准容器的toString方法将迭代容器,并在每一个元素上调用toString来生成容器内容的格式化表示。

class HiddenIterator{
    private final Set set = new HashSet();
    public void addTenThings(){
        Random random = new Random();
        for (int i = 0; i < 10; i++) {
            set.add(random.nextInt());
        }
        //存在隐藏迭代器
        System.out.println("DEBUG: added ten elements to " + set);
    }
}

容器的hashCode和equals等方法也会间接的执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll、removeAll和retainAll等方法,以及把容器作为参赛的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可以抛出 ConcurrentModificationException。

5.2并发容器

通过并发容器来代替同步容器,可以极大的提高伸缩性并降低风险。

Map map = new HashMap();//不安全的容器
Map map1 = new Hashtable();//同步容器
Map map2 = new ConcurrentHashMap();//并发容器

5.3阻塞队列和生产者-消费者模式

在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止生产过度的工作项,使应用程序在负荷过载的情况下变的更加健壮。

5.5同步工具类

阻塞队列可以作为同步工具类,其它类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。

5.5.1闭锁

闭锁是一种同步工具类。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程可以通过,当到达结束状态时,这扇门会打开并且允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其它活动都完成后才继续执行。

public class CountDownLatchDemo {
    //测试10个线程并发执行需要多长时间public static void main(String[] args) throws Exception {
        int threadNum = 10;
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch endGate = new CountDownLatch(threadNum);
        for (int i = 0; i < threadNum; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        startGate.await();
                        System.out.println("tun task...");
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        endGate.countDown();
                    }

                }
            }).start();
        }

        long start = System.currentTimeMillis();

        startGate.countDown();
        endGate.await();
        long end = System.currentTimeMillis();

        System.out.println(end - start);


    }
}
5.5.3信号量

计数信号量(Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。当信号量的参数为1时,作用和排它锁相似。

public class SemaphoreDemo {

    public static void main(String[] args) throws Exception {
        //设置3个资源
        Semaphore semaphore = new Semaphore(3);

        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {

                    String name = Thread.currentThread().getName();
                    try {
                        semaphore.acquire();
                        System.out.println(name+"获得信号量");

                        System.out.println("do task...");


                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        semaphore.release();
                        System.out.println(name+"释放信号量");
                    }


                }
            }, "threadID-" + i).start();
        }


    }
}
5.5.4栅栏

CyclicBarrier

总结

  • 可变变量是至关重要的。

所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。

  • 尽量将域声明为final 类型,除非需要它们是可变的。
  • 不可变对象一定是线程安全的。

不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。

  • 封装有助于管理复杂性。

在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。

  • 用锁来保护每个可变变量。
  • 当保护同一个不变性条件中的所有变量时,要使用同一个锁。
  • 在执行复合操作期间,要持有锁。
  • 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
  • 不要故作聪明地推断出不需要使用同步。
  • 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。
  • 将同步策略文档化。

第6章 任务执行

6.1在线程中执行任务

6.1.3无限制创建线程的不足

线程生命周期的开销非常高。线程的创建与销毁不是没有代价的。线程的创建过程会需要时间,延迟处理的请求。

资源消耗。如果你已经拥有足够多的线程使所有的CPU处于忙碌状态,那么创建更多的线程反而会降低性能。

稳定性。在可创建线程的数量上存在一个限制,如果破坏了这些限制,那么很有可能出现OOM异常。

6.2 Executor框架

6.2.2 执行策略

每当看到下面这种形式的代码时:

 new Thread(runnable).start();

并且你希望获得一种更加灵活的执行策略时,请考虑使用Executor来替代Thread。

6.2.3 线程池

线程和线程池

6.3找出可利用的并行性

6.3.5 CompletionService: Executor与BlockingQueue

如果向Executor提交一组任务,并且希望计算完成后获得结果,那么你可以保留与每一个任务关联的Future,然后反复使用get方法,这种方法可行,但是繁琐。

如下面代码所示,我提交一5个执行随机时间的任务,当执行完毕后,completionService.take()就会返回执行完毕的那一个。

import java.util.Random;
import java.util.concurrent.*;

/**
 * @author CBeann
 * @create 2020-02-20 2:49
 */
public class CompletionServiceDemo{

    public static void main(String[] args) throws Exception {

        CompletionService completionService = new ExecutorCompletionService<Integer>(new Myexecutor());
        Random random = new Random();
        int threadNum = 5;

        for (int i = 0; i < threadNum; i++) {
            completionService.submit(new Callable() {
                @Overridepublic Object call() throws Exception {

                    int nextInt = random.nextInt() % 10;//10秒以内if (nextInt <= 0) nextInt += 10;
                    try {
                        System.out.println("业务逻辑执行时间: " + nextInt);
                        TimeUnit.SECONDS.sleep(nextInt);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }

                    return nextInt;
                }
            });
        }

        int sum = 0;
        for (int i = 0; i < threadNum; i++) {
            Future take = completionService.take();
            Integer o = (Integer) take.get();
            sum += o;

        }
        System.out.println(sum);

    }
}


class Myexecutor implements Executor {
    @Overridepublic void execute(Runnable command) {
        new Thread(command).start();

    }
}

第7章 取消与关闭

很重要,因为我看不懂(我好菜啊)

第8章 线程池的使用

线程和线程池

在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖其他的任务,那么会要求线程池足够大,从而确保它们依赖的任务不会被放入等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。通过将这些需求写入文档,将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性或活跃性。

每当提交了一个由依赖性的Executor任务时,要清楚的知道可能会出现线程“饥饿”死锁,因此需要在代码或配置Executor的配置中心记录线程池的大小限制或配置限制。

第10章 避免活跃性危险

在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但是如果过度的使用加锁,则可能导致顺序死锁。同样,我们使用线程池和信号量来限制资源的使用,但这些限制的行为可能会导致资源死锁。

10.1死锁

线程A等待线程B所占有的资源,而线程B等待线程A所占有的资源,如果在图中形成一个环路,那么就存在一个死锁。

10.1.1锁顺序死锁

如下图所示,一个线程拥有left锁,去尝试right锁,而一个线程拥有right锁,去尝试left锁,就产生死锁。

class LeftRighrDeadLock {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
        synchronized (left) {
            synchronized (right) {
                System.out.println();
            }
        }
    }

    public void rightLeft() {
        synchronized (right) {
            synchronized (left) {
                System.out.println();
            }
        }
    }
}
10.1.2动态的锁顺序死锁

有的时候我们并不清楚是否在锁顺序上有足够的控制权来避免死锁的发生。正如转账所示,所有的线程都似乎安装相同的顺序来获得锁,但是事实上锁的顺序取决于参数顺序,如下面的代码所示。

    transferMoney(myAccount,yourAccount,10);
    transferMoney(myourAccount,myAccount,,20);

要解决上面的问题,必须定义锁顺序,该方法将返回由Object.hashcode返回的值定义锁的顺序。虽然增加了额一些代码,但是消除了发生死锁的可能性。如果Account中包含一个唯一的,不可变的,并且具备可比性的键值,那就不需要例如下面代码中“加时赛”锁作用的lock。

    private final Object lock = new Object();
    public void transferMoney(Object fromAcct, Object toAcct, int num) {
        int fromHash = fromAcct.hashCode();
        int toHash = toAcct.hashCode();
        if (fromHash < toHash) {
            synchronized (fromAcct) {
                synchronized (toAcct) {
                    System.out.println("do something...");
                }
            }
        } else if (toHash < fromHash) {
            synchronized (toHash) {
                synchronized (fromHash) {
                    System.out.println("do something...");
                }
            }
        } else {
            synchronized (lock) {
                synchronized (toHash) {
                    synchronized (fromHash) {
                        System.out.println("do something...");
                    }
                }
            }
        }

    }
10.1.3 在协作对象直接发生的死锁

如下面代码所示,线程A调用car.carMethod方法时,拥有自己锁并且尝试carList的锁。如果此时线程B调用carList.carListMethod方法时,拥有自己的锁,并且尝试car的锁时,就发生了死锁。

class Car {
    private String name;
    private CarList carList;
    public synchronized void carMethod(String name) {
        this.name = name;
        carList.carListMethod(this);
    }
}

class CarList {
    private List list = new ArrayList();
    public synchronized void carListMethod(Car car) {
        boolean contains = list.contains(car);
        if (!contains) {
            car.carMethod();
        }
    }
}
10.1.4 开放调用

如果在调用某个方法时不需要持有锁,那么这种调用方法就是开放调用。

    //开放调用的反例public synchronized void method(){
       otherClassInstence.synchronizedMethod();
   }
   //开放调用public  void method(){
       synchronized (this){
           doSomeThing();
       }
        otherClassInstence.synchronizedMethod();
    }

有的时候会丢失原子性。

在程序中应尽量使用开放调用。与那些在持有锁的时候调用外部方法的程序相比,更容易对依赖开放调用的程序进行死锁分析。

10.2死锁的避免与诊断

10.2.1 支持定时的锁
 Lock lock = new ReentrantLock();
        try {
            lock.tryLock(10, TimeUnit.SECONDS);
            //logic
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
10.2.2通过线程转储信息来分析死锁

Java并发编程实战

10.3其他活跃性危险

10.3.1饥饿

要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级

10.3.3活锁

活锁(Livelock)是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且,总会失败。 活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息,Poison Message. )虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。

第11章 性能与可伸缩性

11.1 对性能的思考

要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有的处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。

11.1.1 性能与可伸缩性

应用程序的性能可以采用多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一 定的情况下,能完成“多少”工作。

可伸缩性指的是:当增加计算资源时(例如CPU、内存、存储容量或1/O带宽),程序的吞吐量或者处理能力能相应地增加。

11.1.2 评估各种性能权衡因素

避免不成熟的优化。首先使程序正确,然后在提高运行速度-----如果它还运行的不够快。

以测试为基准,不要猜测。

11.2 Amdahl定律

Amdahl定律:在增加计算资源的情况下,程序理论上能够实现最高加速比,取决于程序中可并行组件与串行组件所占的比重。

如下面代码所示,串行化的部分是queue.take()

class WorkerThread extends Thread {
    private final BlockingQueue<Runnable> queue;
    public WorkerThread(BlockingQueue<Runnable> queue) {
        this.queue = queue;
    }
    @Override
    public void run() {
        while (true) {
            try {
                Runnable task = queue.take();
                task.run();
            } catch (Exception e) {
                break;
            }
        }
    }
}

在所有的并发程序中都包含一些串行化部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细的检查一遍。

11.3 线程引入的开销

对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

11.3.1 上下文切换

切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。

11.3.2 内存同步

现代的JVM能通过优化去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。例如,JVM通常会去掉下面代码中的锁获取操作。

//没有作用的同步(不要这么做)synchronized (new Object()){
            //do
        }

JVM也可以执行锁粗化(Lock Coarsening)操作,将临近的同步代码块用一个锁合并起来。如下面代码所示,3个add和1个toString调用合并为单个锁获取/释放操作。

    public String getNames(){
        List list = new Vector();
        list.add("张三1");
        list.add("张三2");
        list.add("张三3");
        return list.toString();
    }

11.4 减少锁的竞争

在并发程序中,对可伸缩性的最主要微威胁就是独占方式的资源锁

11.4.1 缩小锁的范围("快进快出")

降低发生竞争可能性的一种有效方法是尽可能的缩短锁的持有时间。

class Demo {
    private final Map map = new HashMap();

    //错误案例public synchronized void putIfAbsent(String name, String val) {
        String key = "users." + name + ".location";
        if (!map.containsKey(key)) {
            map.put(key, val);
        }
    }
    //正确案例public void putIfAbsent(String name, String val) {
        String key = "users." + name + ".location";
        synchronized (this){
            if (!map.containsKey(key)) {
                map.put(key, val);
            }
        }
    }
    
}
11.4.2 减小锁的粒度

锁分解前

//使用一个锁
class Demo {
    private final Map map = new HashMap();
    private final Set set = new HashSet();

   
  
    public synchronized void putIfAbsentMap(String name, String val) {
        String key = "users." + name + ".location";
        synchronized (this){
            if (!map.containsKey(key)) {
                map.put(key, val);
            }
        }
    }

    public synchronized void putIfAbsentSet(String name) {
        String key = "users." + name + ".location";
        synchronized (this){
            if (!set.contains(key)) {
                set.add(key);
            }
        }
    }

}

锁分解后

//锁分解
class Demo {
    private final Map map = new HashMap();
    private final Set set = new HashSet();


    public void putIfAbsentMap(String name, String val) {
        String key = "users." + name + ".location";
        synchronized (map) {
            if (!map.containsKey(key)) {
                map.put(key, val);
            }
        }
    }

    public void putIfAbsentSet(String name) {
        String key = "users." + name + ".location";
        synchronized (set) {
            if (!set.contains(key)) {
                set.add(key);
            }
        }
    }

}
11.4.3 锁分段

每一段使用一个锁。可以看jdk1.8以前(不包括jdk1.8)的ConcurrentHashMap中的分段锁。

//锁分段
class Demo {


    //比如我设置16个锁
    Object[] locks = new Object[16];

    private int[] data = new int[32];

    public void updateData(int index, int val) {
        synchronized (locks[index % 16]) {
            data[index] = val;
        }
    }

}

锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需要获得一个锁,但是在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。

11.4.5 一些替代独占锁的方法

并发容器、读-写锁、不可变对象以及原子变量。

11.4.7 向对象池说“不”

通常,对象分配操作的开销比同步的开销更低。

小结

由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。

第13章 显式锁

13.1 Lock与ReentrantLock

Lock提供了一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有的加锁和解锁的方法都是显示的。ReentrantLock实现了Lock接口,并且提供了与synchronized相同的互斥性和内存可见性。

  • 轮询锁与定时锁
  • 可中断的锁获取操作
  • 非块结构的加锁

Lock接口的标准使用形式如下:

        Lock lock = new ReentrantLock();
        lock.lock();
        try {
            //do logic
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

13.2 性能考虑因素

性能是一个不断变化的指标,如果在昨天的测试基准中发现X比Y更快,那么在今天可能已经过时了。

13.3 公平性

在ReentrantLock的构造函数中提供了两种公平性选择:创建一 个非公平的锁(默认)或者一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。(在Semaphore中同样可以选择采用公平的或非公平的获取顺序。)非公平的ReentrantLock并不提倡“插队”行为,但无法防止某个线程在合适的时候进行“插队”。在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出的请求的线程才会被放入队列中。 等执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低性能。在大多数情况下,非公平性锁的性能要高于公平性锁。

在激烈竞争的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂的线程与该线程真正开始运行之间存在着严重的延迟。假设线程A持有一个铺,并且线程B青求这个锁。由于这个锁已被线程A持有,因此B将被挂起。当A释放锁时,B将被唤醒,因北会再次尝试获取锁。 与此同时,如果C也请求这个锁,那么C很可能会在B被完全嗅醒之前获得、使用以及释放这个锁。这样的情况是一种“双赢”的局面:B获得锁的时刻并没有推迟,C更早的获得锁,并且吞吐量也获得了提高。

13.4 在synchronized和ReentrantLock之间进行选择

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列已经非块结构的锁。否则,还是应该优先使用synchronized。

13.5 读写锁

当访问以读操作为主的数据结构时,它能提高程序的可伸缩性。

        ReadWriteLock lock = new ReentrantReadWriteLock();
        Lock writeLock = lock.writeLock();
        Lock readLock = lock.readLock();

        writeLock.lock();
        try {
            
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
        }

第14章 构建自定义的同步工具

14.1 状态依赖的管理

通常,如果线程在休眠或者被阻塞时持有一个锁,那么这通常是一种不好的做法,因为只要线程不释放这个锁,有些条件就永远无法成真。

“条件队列”这个名字来源:它使得一组线程(称之为等待线程的集合)能够通过某种方式来等待特定的条件变为真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。

14.2 使用条件队列

14.2.1 条件谓语

条件谓语是使某个操作称为状态依赖操作的前提条件。在阻塞队列中,只有当队列不为空 时,take方法才能执行,否则必须等待。对take方法来说,它的条件谓词就是“队列不空”。

每一次wait调用都会隐式的域特定的条件谓语关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护这构成条件谓词的状态变量。

14.2.2 过早唤醒

当使用条件等特时(例如Object.wait或Condition.await):

  • 通常都有一个条件谓词一 包括一 些对象状态的测试,线程在执行前必须首先通过这些测试。
  • 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
  • 在一个循环中调用wait。
  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
  • 当调用wait、 notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
  • 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
14.2.3 丢失的信号

只有同时满足以下两个条件时,才能使用单一的notify而不是notifyAll:

所有等待线程的类型相同。只有一个条件谓语与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。

单进单出。 在条件变量上每次通知,最多只能唤醒一个线程来执行。

14.3 显式的Condition对象

在Condition对象中,与wait、notify和notifyAll方法对应的分别是await、signal和signalAll。但是,Condition对Object进行可扩展,因此也包含wait、notify和notifyAll方法。一定要使用正确的版本。

14.5 AbstractQueuedSynchronizer(AQS)(☆☆☆☆☆)

很重要,但是书中将的很粗略

14.6 java.util.concurrent同步器类中的AQS(☆☆☆☆☆)

ReentrantLock

Semaphore与CountDownLatch

FutureTask

ReentrantReadWriteLock

第15章 原子变量与非阻塞同步机制

15.1 锁的劣势

1)如果有多个线程同时请求锁,那么一些线程将被挂起并且稍后恢复运行。当线程恢复时,必须等待其他线程执行完他们的时间片以后,才能被调度使用。在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。

2)当一个线程正在等待锁时,它不能做任何事情。如果一个持有锁的线程被延迟执行(例如发生了缺页错误、调度延迟等),那么所有需要这个锁的线程都无法执行下去。

3)如果被阻塞的线程的优先级较高,而持有锁的线程的优先级较低,那么这将是一个严重的问题-----优先级反转。

15.2 硬件对并发的支持

CAS的主要缺点:使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前一直阻塞)。

在大多数处理器上,在无竞争的锁获取和释放的“快速代码路径”上的开销,大约是CAS开销的两倍。

15.3 原子变量类

AtomicInteger、AtomicLong、AtomicReference、AtomicReferenceFieldUpdater、AtomicStampedReference、AtomicMarkableReference

锁与原子变量在不同竞争程度上的性能差异很好得说明了各自的优势和劣势。在中低程度的竞争下,原子变量能提供更高的可伸缩性。在高强度的竞争下,锁能干有效的避免竞争。

15.4 非阻塞算法

创建非阻塞算法的关键在于:找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。

第16章 Java内存模型(JMM)

此内容参考《深入理解java虚拟机》

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

原子性

线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。

缓存一致性(可见性)

在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

有序性

除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是有序性问题。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

总结

1)总结的不是很到位,有些没看懂就省略了。

2)学习知识是一个潜移默化的过程,学完后也许看不出成果。

3)年轻人的世界没有容易的,每天进步,足矣。