那些年你不知道的并发知识(上)

知识摘要: 会讲线程安全 锁的一些相关. volatile变量 竞态条件 线程的原子性和可见性, 指令重排序. 对象的安全发布和逸出. 和一些并发的工具(信号量 栅栏 闭锁 future) 多线程与算法结合

线程安全性

以前我们编写串行化的知识,很少考虑到你的结果是不是因为非逻辑的错误.
在多线程的情况下.就不得不为安全性去考虑了.
例如:有两个线程共享一个资源(变量i) 这个i的初值比如是2, 两个线程同时对他做+1的操作,我们期望他变成4
如果这两个线程同时读取变量i的值(这个时候就是初值2) 然后同时执行+1的操作再写会内存得到的结果就是3是一个脏读数据
后文会详细介绍
比如,你统计你Web服务器一天的访问量.如果有大量的数据的时候,这个时候因为并发会少一些数据这种是可以忍受的.
但是如果银行取钱A和B同时去对卡里取钱,卡里有500 A取了300 同时 B取400. 不考虑安全的情况很有可能两个人都会取到钱.这对银行是绝对不能容忍的.


对象是否是线程安全的

一个对象是否为安全的取决于它是否被多个线程共享.

public class MyThreadA implements Runnable {
    private int count = 0;
    @Override
    public void run() {
        
    }
}

例如这个代码如果传给多个线程使用 count就是非线程安全的状态

Servlet是线程安全的吗?
Servlet发布的时候只会创建一个Servlet实例.但是如果Servlet有状态的话就会出现线程不安全的情况
下面的servlet就存在线程不安全

public class AServlet extends HttpServlet {
    private int count = 0;
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.doGet(req, resp);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.doPost(req, resp);
    }
}

上面的这个如果在Get或者Post方法里使用count就可能存在并发问题

public class AServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

这样就是线程安全的 因为它是栈封闭(待会说)

public class MyThreadA implements Runnable {
    private int count = 0;
    @Override
    public void run() {
        while(count < 100000) {
            System.out.println(Thread.currentThread() + " --- " + count++);
        }
    }
}

public class Demo {
    private static final int N = 5;
    public static void main(String[] args) {
        Runnable runnable = new MyThreadA();
        for(int i = 0; i < N; i++) {
            Thread thread = new Thread(runnable);
            thread.start();
        }
    }
}

这个代码就是线程不安全的, 他可能会输出一些错误的数据.
因为count在多个线程之间是共享的
但是下列是println的源码

public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

println是线程安全的但是为什么会出现脏读数据呢?
是因为i++不是一个原子性的操作.
i++这条语句实际包含了三个动作,
1.取出内存中的值i
2.将i的值+1
3.将i的值写回到内存,

在这里插入图片描述
第一个线程取得i的值发现是1, 刚加完1的时候 第二个线程进来读到i的值发现也是1
这样的数据就是有问题的

原子性

原子性指某一个操作是不可再分的.简单来说.一个操作,要么直接成功, 要么直接失败.

public class MyThreadA implements Runnable {
    private int count = 0;
    @Override
    public void run() {
        System.out.println(getCount());
    }
    
    public int getCount() {
        return count;
    }
}

比如这个操作,那么就是原子性的.因为它只包含了一步,一步读取count的值
再举一个例子.银行的转账就必须保证是原子性的. A给B转钱100
这却包含的不是一个步骤,但是必须保证这是原子性的.
这其中包含着A的钱减少100, B的钱增加100
如果A减少完100出了某些错误,转账终止了,A的钱减少了100但是B的钱没有增多
所以必须保证这个操作是原子的 要么都失败 要么都成功.

如何保证原子性?
出现问题都是在多线程环境下, 如果保证在一个时间只有一个线程做操作那么就可以保证原子性.
所以加上synchronized关键字就可以

public class MyThreadA implements Runnable {
    private int count = 0;
    @Override
    synchronized public void run() {
        while(count < 100000) {
            System.out.println(count++);
        }
    }
}

安全性与性能

安全和性能这两个本来就是比较矛盾的之前说加synchronized
这样某一个操作只有单个线程去做这个操作,失去了多线程的一些优势.而且由于存在上下文切换 和获取锁释放锁的操作甚至会比串行程序慢很多
越多下面这个操作(千万不要这样做)

public class MyThreadA implements Runnable {
    private int count = 0;
    @Override
    synchronized public void run() {
        int[] arr = new int[50];
        for (int i = 0; i < arr.length; i++) {
            arr[i] = i;
        }
        while(count < 100000)
            System.out.println(count++);
    }
}

在这里插入图片描述
这一块的操作在多线程的情况下 其实根本不会影响什么

锁分解
public class MyThreadA implements Runnable {
    private int count = 0;
    @Override
    public void run() {
        int[] arr = new int[50000];
        for (int i = 0; i < arr.length; i++) {
            arr[i] = i;
        }
        while(count < 100000){
            synchronized (this) {
                System.out.println(count++);
            }
        }
    }

这种情况就会比上面好很多. 如果一个线程获得锁的时间很长, 其他线程阻塞时间很长想去获得锁就会产生饥饿 问题.所以锁的话值需要锁到需要锁的可能会出现并发安全问题的位置就可以提高一定的性能.


死锁

什么是死锁呢?A和B两个线程都需要访问资源 A和B线程访问资源都需要获得两个锁S1和S2
A拿到了S1锁 还想拿S2锁 B拿到了S2锁想要拿S1锁, A等着B释放S2锁 B等着A释放S1锁

public class Demo {
    private static final Object LOCKA = new Object();
    private static final Object LOCKB = new Object();

    class ThreadA extends Thread{
        @Override
        public void run() {
            synchronized (LOCKA) {
                try {
                    Thread.sleep(1000);//模拟做一些操作
                    synchronized (LOCKB){
                        
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    class ThreadB extends Thread{
        @Override
        public void run() {
            synchronized (LOCKB) {
                try {
                    Thread.sleep(500);//模拟做一些操作
                    synchronized (LOCKA){
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

这样的两个线程就会永远的阻塞下去.
这是根据锁顺序产生的死锁

public class MyThreadA implements Runnable {
    private final Object A;
    private final Object B;

    public MyThreadA(Object a, Object b) {
        A = a;
        B = b;
    }

    @Override
    public void run() {
        synchronized (A) {
            try {
                Thread.sleep(1000);//做一些操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (B) {
                
            }
        }
    }
    

这里是客户端给这个对象传递锁,如果在多线程情况可能会出现死锁问题

public class Demo {
    private static final Object LOCKA = new Object();
    private static final Object LOCKB = new Object();

    public static void main(String[] args) {
        new Thread(new MyThreadA(LOCKA, LOCKB)).start();
        new Thread(new MyThreadA(LOCKB, LOCKA)).start();
    }
}

例如这个样子. 你自己写的类中的锁是没问题的. 但是你不知道客户端会怎样给你传着两个对象
比如我上面的样子
所以这样的情况是你自己写出锁顺序的位置

public class MyThreadA implements Runnable {
    private final Object A;
    private final Object B;

    public MyThreadA(Object a, Object b) {
        A = a;
        B = b;
    }

    @Override
    public void run() {
        int hashA = System.identityHashCode(A);
        int hashB = System.identityHashCode(B);
        if(hashA < hashB) {
            function(A,B);
        } else if(hashA > hashB) {
           function(B,A);
        }
    }
    
    public void function(Object A, Object B) {
        synchronized (A) {
            try {
                Thread.sleep(1000);//做一些操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (B) {
            }
        }
    }
}

更好的写法 通过hash值算出每个锁的hash值大小, 可以根据你的规则给出锁的顺序
这样就不会产生死锁的问题
但是上面的代码还是有问题的. 如果两个对象的hash值是一样的怎么办.
虽然这种情况也确实很少, 不过也可能存在.
如果是一样的就给出一种锁竞争的情况

public class MyThreadA implements Runnable {
    private final Object A;
    private final Object B;

    public MyThreadA(Object a, Object b) {
        A = a;
        B = b;
    }

    @Override
    public void run() {
        int hashA = System.identityHashCode(A);
        int hashB = System.identityHashCode(B);
        if(hashA < hashB) {
            function(A,B);
        } else if(hashA > hashB) {
           function(B,A);
        } else {
            //两个hash值一样的情况
            Object C = new Object();
            synchronized (C) {
                function(A,B);
            }
        }
    }

    public void function(Object A, Object B) {
        //这里的A和B是上面run方法传递过来的,只要保证每次顺序都是一样的就不会出现死锁
        
        synchronized (A) {
            try {
                Thread.sleep(1000);//做一些操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (B) {
            }
        }
    } 
}
栈封闭

可以简单的理解就是一个方法的调用, 不设计到共享变量. 多线程调用这个方法的时候都会有他们自己的变量.
不会做到共享就是线程安全的.

竞态条件

有两个线程安全的状态再一起操作的时候会是线程安全的吗?
引自<<java并发编程实战>>的一个例子
你和你的一个朋友在某某商区的星巴克见面, 可是那里有两家星巴克你去了星巴克A没见到你的朋友 那么就有可能 你朋友还没来, 或者他在星巴克B 或者他失约了. 这个时候你去到星巴克B也没见到你朋友 就有四种可能 他到了星巴克A. 或者他没来 或者他从星巴克B从另一条路到了星巴克A 或者他 失约了.
同时你的朋友也可能这样想.
除非你们达成了某种协议,否则你们俩可能整天都在走来走去. 这就是并发的问题…

如果出现竞态条件 那么结果可能就会变得不可靠.
竞态条件的想要得到正确的结果,就必须取决于事件发生的时序
如果结果取决于事件发生的时序也就是交给运气,就会变得相当不可靠

在Collections静态工厂里封装了很多线程安全类 比如Collections.synchronizedList(new ArrayList<>());
这样的一个线程安全的类

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

public class ListHelper<T> {
    public List<T> list = Collections.synchronizedList(new ArrayList<>());
    
    synchronized public boolean putIfAbsent(T t) {
        boolean absent = !list.contains(t);
        if(absent) list.add(t);
        return absent;
    }
}

list是线程安全的, 下面的这个方法也是线程安全的.但是还是会出现问题
因为这里存在竞态条件,在得到contains的时候可能这个时候有其他的线程去添加了这个值. 因为他们上的也不是同一把锁

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

public class ListHelper<T> {
    public List<T> list = Collections.synchronizedList(new ArrayList<>());

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

所以这个样子就没什么问题了


CAS操作Compare And Swap)

比较并交换
CAS有3个值 一个是内存值V(也就是期望值), 还有两个参数,A,B
CAS就是 我认为你的V应该等于A 如果相等 我把V替换成B 否则我什么也不做
最经典的就是死循环加上CAS操作可以解决并发的情况
用CAS 处理 i++

for(;;) {
             int A = get();
             int B = A + 1;
             if(compareAndSet(A, B)) {
                 return;
             }
         }

A 得到当前的值, 把当前的值加1.
如果你内存的值等于我A的值 我就把他设置成B.
如果不相等 这里就说明发生了并发, 我就不做任何事情,继续循环.
当然这里也可能出现一个ABA的问题
如果线程1进来得到了内存值A 他想把值改成B.
这个时候有其他的线程过来把内存的值也读到了A改成了B
同时有另外的线程把B改成了A
这个时候线程1去修改的时候发现内存值是A 你传递的也是A 所以修改B成功.
其实这里是发生了并发的问题的.但是修改成功了.
当然从JDK1.5以后就会加上了一个原子类型的版本号. 其实就是判断他是否被更改过了


线程的可见性和volatile变量

下列程序的结果

public class Test extends Thread{
    private static boolean flag;
    private static int k;

    @Override
    public void run() {
        while(!flag)
            Thread.yield();
        System.out.println(k);
    }

    public static void main(String[] args) {
        new Test().start();
        k = 99;
        flag = true;
    }
}

这个的答案取决于你的jdk版本和编译器的一些处理.比如我是在JDK10下运行这段代码的,总是可以得到正确的结果
但是可能JDK以前的版本和编译器的话可能会让结果有一些问题
这个方法可能会持续循环下去, 因为线程用看不到读的flag的值.
也有可能会输出0 因为存在指令重排序的问题. main函数里执行的显示flag = true;
正好Test线程读到了 并且输出了k k是0
可见性是一种复杂的属性, 因为可见性的错误总会让人违背直觉.
如果在串行化程序中,某个变量先写入值,再读取值.总是可以得到正确的结果, 这很自然.但是在多线程环境下总是会出现一些稀奇古怪的结果.
两个线程内部可能有保留的内存变量的副本, 一个更改了副本 但是另外一个线程没有收到. 所以无法得到正确的数据
幸运的事情是 可以使用volatile修饰变量
可以更改flag private volatile static boolean flag;
被volatile修饰的变量, 编译器和运行时都会注意到这个变量是共享的.因此不会将该变量上的操作与其他内存操作一起排序.
volatile变量也不会被缓存到寄存器或者对其他处理器不可见的地方.因此读取volatile修饰变量时总会返回最新写入的值.
volatile是一种轻量级的锁, 它比synchronized更加轻量.但是却更加脆弱(待会说)
当然并不是越多使用越好volatile会破坏编译器的指令重排序(编译器对程序的一种优化)
volatile的变量其实会被加上LOCK指令(这里不去深入讲解)可以参考<<JAVA并发编程艺术>>

volatile与原子性

上文说明了volatile具有可见性, 但是具备原子性吗?
其实思考一下就明白了 volatile不具原子性.
所以上文说道volatile比比synchronized更加脆弱也就是因为它只保证了可见性
volatile只保证了内存的可见性, 而加锁机制即保证了内存的可见性又保证了原子性
由于volatile变量不具备原子性. 所以如果你不能保证只有一个线程对他写 就用加锁机制
请不要大量的使用volatile变量, 只有你真正需要使用它的时候再去使用


发布与逸出

发布一个对象是指 : 使得对象可以在当前作用域之外的代码中使用. 例如使用非私有的方法返回一个对象的引用. 使得其他类可以使用这个对象.而在许多情况下, 我们需要对象的内部状态不被发布出去.发布一个内部的状态会破坏封装性.(封装性会降低吃程序性能)
下面的代码就会发布这个内部状态**(不要这么做)**

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

public class UnsafeDemo {
    private List<String> list = new ArrayList<>();

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

逸出是指一个对象还没有被初始化完成前就已经被发布出去了.
这样就会破坏线程的安全性.
例如

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

public class UnsafeDemo {
    private List<String> list;

    public void initialize() {
        list = new CopyOnWriteArrayList<>();
    }
}

可能还没有进行初始化就访问了状态

不可变性

不可变对象一定是线程安全的
如果一个状态已经是不可变的了 所以,他总是线程安全的 这句话很自然.
写代码的一个好的习惯就是, 如果不需要再去改变他的引用了 或者 某一个状态不会再改变了 都加上final
不可变对象很简单. 它们只有一种状态, 并且该状态由构造函数来控制.
一个困难的地方就是判断复杂对象的可能状态
当满足以下条件时, 对象才是不可变的
1. 对象创建以后其状态就不能修改
2. 对象的所有域都是final类型的
3. 对象是正确创建的(没有this逸出)

import java.util.HashSet;
import java.util.Set;

public class States {
    private final Set<String> set = new HashSet<>();

    public States() {
        set.add("刘亦菲");
        set.add("唐嫣");
        set.add("黄圣依");
    }

    public boolean isExist(String name) {
        return set.contains(name);
    }
}

上面代码set仔对象构造完成后无法对其进行修改. 是一个final类型的引用变量.
所有状态都通过一个final域来进行访问

安全发布

下列代码在没有足够的同步下发布了一个对象(不要这么做)

public Holder holder;
    
    public void initialize() {
        holder = new Holder(99);
    }

由于未被正确的发布 所以这个类可能会出现问题

public class Holder {
    private int n;

    public Holder(int n) {
        this.n = n;
    }

    public void assertSanity() {
        if(n != n)
            throw new AssertionError();
    }

}

下面用一个例子演示一下几种安全的发布对象
图形类维护了一个 坐标点序号和坐标点的Map
getPoints返回一个Map的只读副本
Collections.unmodifiableMap使这个map变成视图 只读模式

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class Pattern {
    private final Map<Integer, Point> points;

    public Pattern(Map<Integer, Point> points) {
        this.points = points;
    }

    synchronized public Map<Integer, Point> getPoints() {
        return deepCopy(points);
    }

    synchronized public Point getPoint(Integer id) {
        Point point = points.get(id);
        return point == null ? null : new Point(point);
    }

    synchronized public void setPoint(Integer id, int x, int y) {
        Point point = points.get(id);
        if(point == null) throw new IllegalArgumentException();
        point.x = x;
        point.y = y;
    }

    private static Map<Integer, Point> deepCopy(Map<Integer, Point> m) {
        Map<Integer, Point> result = new HashMap<>();
        for (Integer id : m.keySet()) {
            result.put(id, new Point(m.get(id)));
        }
        return Collections.unmodifiableMap(result);
    }
}

下面代码可变的Point(不要这么做)

public class Point {
    public int x, y;

    public Point() {
        x = 0; y = 0;
    }

    public Point(Point p) {
        this.x = p.x;
        this.y = p.y;
    }
}

虽然图案类是线程安全的. 无论返回什么都是创建一个新的副本.
通常情况下, 这并不存在性能问题. 但是在像素点特备多的时候 图案容器将极大的降低性能.
在你创建副本的时候 可能像素点的坐标发生了变化, 但是你得到的是旧的快照.
这种情况的好坏取决于你的需求.
如果你需要频繁的知道每个像素点的信息 那么这就是缺点.
如果保证集合上的一致性需求 这就是优点.
在讲接下来的安全发布之前先将一个类

线程安全的Map(ConcurrentHashMap)

这里不会去详细说明这个类. 它的实现还是很复杂的
在jdk7的版本它使用的是分段锁. 整个散列桶维护了16把锁. 每把锁维护了桶的1/16通过第一次散列找到锁的位置 第二次散列再在这个1/16中做数据操作 这样的性能比HashTable 高了太多 而且还是线程安全的类.就算是串行环境下也有不错的性能
jdk8的版本则使用了CAS的操作
好了 继续回来
不可变的Point类

public class Point {
    public final int x, y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

由于Point类是不可变的, 因而它是线程安全的. 不可变的值可以自由共享.
这个时候返回的Points不需要复制
下列代码没有显示的同步, 所有同步都委托给了ConcurrentHashMap

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class Pattern {
    private final Map<Integer, Point> points;
    private final Map<Integer, Point> unmodifiableMap;

    public Pattern(Map<Integer, Point> points) {
        this.points = new ConcurrentHashMap<>(points);
        unmodifiableMap = Collections.unmodifiableMap(points);
    }
    
    public Map<Integer, Point> getPoints() {
        return unmodifiableMap;
    }
    
    public Point getLocation(Integer id) {
        return points.get(id);
    }
    
    public void setPoint(Integer id, int x, int y) {
        if(points.replace(id, new Point(x,y)) == null)
            throw new IllegalArgumentException();
    }
}

如果使用了之前的类, 就会破坏封装性. getPoints会发布一个指向可变状态的引用. 而这个引用不是线程安全的
上述代码值得注意的是 A线程得到了Points 而B线程修改了某些值. 那么A线程也可以反映出这些变化
这可以是一种优点(更新数据), 也可能是一种缺点(就像照片一想, 照片突然会动力 你怕不怕.)
如果你只是想返回一个静态的快照

public Map<Integer, Point> getPoints() {
        return Collections.unmodifiableMap(points);
    }

这些都取决于你的需求

一个线程安全且可变的Point类
如果想想要得到坐标点的值 只是会得到一个副本.这样也不会对安全性做破坏

public class Point {
    private int x, y;
    
    private Point(int[] a) {
        this(a[0], a[1]);
    }
    
    public Point(Point p) {
        this(p.get());
    }
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    synchronized public int[] get() {
        return new int[]{x, y};
    }
    
    synchronized public void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

对于图像类它将线程安全性委托给了底层的ConcurrentHashMap
只是Map中的Point元素是安全且可变的

import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class Pattern {
    private final Map<Integer, Point> points;
    private final Map<Integer, Point> unmodifiableMap;

    public Pattern(Map<Integer, Point> points) {
        this.points = new ConcurrentHashMap<>(points);
        unmodifiableMap = Collections.unmodifiableMap(points);
    }

    public Map<Integer, Point> getPoints() {
        return unmodifiableMap;
    }

    public Point getLocation(Integer id) {
        return points.get(id);
    }

    public void setPoint(Integer id, int x, int y) {
        if(!points.containsKey(id)) throw new IllegalArgumentException();
        points.get(id).set(x, y);
    }
}

给出下卷的连接 :https://blog.csdn.net/qq_42011541/article/details/85990043

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值