欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《java并发编程实战》—— 第五章:基础构建模块

程序员文章站 2024-03-01 18:53:46
...

本系列为本人在研读相关技术书籍后所总结之精华,希望能对大家有所帮助,有兴趣的可以加我好友,大家共同学习进步!

同步容器类

同步容器类包括Vector和Hashtable。这些类实现线程安全的方式是:将它们封装起来,并对每个公有的方法都进行同步,使得每次只有一个线程能访问容器的状态。(线程封闭)

同步容器类的问题

同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作:迭代

//P67  Vectorz中定义的两个方法
public static Object getLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.get(lastIndex);
}
public static void deleteLast(Vector list){
    int lastIndex = list.size() - 1;
    list.remove(lastIndex);
}

《java并发编程实战》—— 第五章:基础构建模块
比如当小县城A在包含10个元素的Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast时,就可能出现异常。

解决方式:客户端加锁(同步代码块)

 public static Object getLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            return list.get(lastIndex);
        }
    }

    public static void deleteLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            list.remove(lastIndex);
        }
    }

同理,在迭代过程中也可能出现异常

for(int i=0;i<vector.size();i++){
        doSomething(vector.get(i));
}
//带客户端加锁的迭代
synchronized (vector){
        for(int i=0;i<vector.size();i++){
        doSomething(vector.get(i));
        }
}

迭代器与ConcurrentModificationException

无论在直接迭代还是在for-each循环中,对容器类进行迭代的标准方式都是Iterator。如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁,否则hashNext或next将抛出ConcurrentModificationException异常。

有时我们并不希望在迭代期间对容器加锁,原因:
1.可能将会产生死锁
2.长时间对容器加锁会降低程序可伸缩性,(持有锁的时间越长,锁上的竞争越激烈,多线程都在等待锁被释放)

那么一种替代的方法就是“克隆”容器,在副本上进行迭代,如CopyOnWriteArrayList

隐藏迭代器

在某些情况下,迭代器会隐藏起来,如调用容器的toString,hashCode,equals,containsAll,removeAll,retainAll方法,以及把容器作为参数的构造函数

并发容器

ConcurrentHashMap:并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的分段锁机制(在第11章中详细说明)。在这种机制中,任意数量的读线程可以并发地访问Map,执行读取操作和写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。

ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改。

对于一些需要在整个Map上进行计算的方法,如size和isEmpty,实际上返回的并不是精确值。虽然这看上去有些令人不安,但事实上size和isEmpty这样的方法在并发环境下的用处很小,因为他们的返回值总是在不断变化。

与Hashtable和synchronizedMap相比,ConcurrentHashMap有着更多的优势,更少的劣势。只有在需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap

额外的原子Map操作

由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作,但是一些常见的复合操作,如"若没有则添加"、“若相同则删除”、“若相同则替换”,都已经实现为原子操作并在ConcurrentHashMap接口中声明。

CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

同样地, CopyOnWriteArrayList的作用是替代同步Set。当迭代操作远远多于修改操作时,才应该使用写入时复制容器。

生产者消费者模式

什么是生产者?
生产者指的是负责生产数据的模块(这里模块可能是:方法、对象、线程、进程)。

什么是消费者?
消费者指的是负责处理数据的模块(这里模块可能是:方法、对象、线程、进程)。

什么是缓冲区?
消费者不能直接使用生产者的数据,它们之间有个“缓冲区”。生产者将生产好的数据放入“缓冲区”,消费者从“缓冲区”拿要处理的数据。

缓冲区是实现并发的核心,缓冲区的设置有3个好处:
1.实现线程的并发协作
有了缓冲区以后,生产者线程只需要往缓冲区里面放置数据,而不需要管消费者消费的情况;同样,消费者只需要从缓冲区拿数据处理即可,也不需要管生产者生产的情况。 这样,就从逻辑上实现了“生产者线程”和“消费者线程”的分离。

2.解耦了生产者和消费者
生产者不需要和消费者直接打交道。

3.解决忙闲不均,提高效率
生产者生产数据慢时,缓冲区仍有数据,不影响消费者消费;消费者处理数据慢时,生产者仍然可以继续往缓冲区里面放置数据 。

public class TestProduce {
    public static void main(String[] args) {
        SyncStack sStack = new SyncStack();// 定义缓冲区对象;
        Shengchan sc = new Shengchan(sStack);// 定义生产线程;
        Xiaofei xf = new Xiaofei(sStack);// 定义消费线程;
        sc.start();
        xf.start();
    }
}
 
class Mantou {// 馒头
    int id;
 
    Mantou(int id) {
        this.id = id;
    }
}
 
class SyncStack {// 缓冲区(相当于:馒头筐)
    int index = 0;
    Mantou[] ms = new Mantou[10];
 
    public synchronized void push(Mantou m) {
        while (index == ms.length) {//说明馒头筐满了
            try {
               //wait后,线程会将持有的锁释放,进入阻塞状态;
               //这样其它需要锁的线程就可以获得锁;
                this.wait();
                //这里的含义是执行此方法的线程暂停,进入阻塞状态,
                //等消费者消费了馒头后再生产。
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 唤醒在当前对象等待池中等待的第一个线程。
        //notifyAll叫醒所有在当前对象等待池中等待的所有线程。
        this.notify();
        // 如果不唤醒的话。以后这两个线程都会进入等待线程,没有人唤醒。
        ms[index] = m;
        index++;
    }
 
    public synchronized Mantou pop() {
        while (index == 0) {//如果馒头筐是空的;
            try {
                //如果馒头筐是空的,就暂停此消费线程(因为没什么可消费的嘛)。
                this.wait();                //等生产线程生产完再来消费;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        this.notify();
        index--;
        return ms[index];
    }
}
 
class Shengchan extends Thread {// 生产者线程
    SyncStack ss = null;
 
    public Shengchan(SyncStack ss) {
        this.ss = ss;
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println("生产馒头:" + i);
            Mantou m = new Mantou(i);
            ss.push(m);
        }
    }
}
 
class Xiaofei extends Thread {// 消费者线程;
    SyncStack ss = null;
 
    public Xiaofei(SyncStack ss) {
        this.ss = ss;
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            Mantou m = ss.pop();
            System.out.println("消费馒头:" + i);
 
        }
    }
}

《java并发编程实战》—— 第五章:基础构建模块
线程并发协作总结:
线程并发协作(也叫线程通信),通常用于生产者/消费者模式,情景如下:
1. 生产者和消费者共享同一个资源,并且生产者和消费者之间相互依赖,互为条件。
2. 对于生产者,没有生产产品之前,消费者要进入等待状态。而生产了产品之后,又需要马上通知消费者消费。
3. 对于消费者,在消费之后,要通知生产者已经消费结束,需要继续生产新产品以供消费。
4. 在生产者消费者问题中,仅有synchronized是不够的。
· synchronized可阻止并发更新同一个共享资源,实现了同步;
· synchronized不能用来实现不同线程之间的消息传递(通信)。
5. 那线程是通过哪些方法来进行消息传递(通信)的呢?见如下总结:
《java并发编程实战》—— 第五章:基础构建模块
这些都只能在同步方法或者同步代码块中使用(需要获得相应的锁),否则会抛出异常。

总结:
1.可变状态越少,越容易确保线程安全性
2.尽量将域声明为final类型,除非需要它们是可变的
3.不可变对象一定是线程安全的
4.保护一个不变性条件的所有变量时,要使用同一个锁
5.在执行复合操作时,要持有锁
6.多线程访问同一可变变量时,如果没有同步机制,那么程序会出现问题