并发编程相关知识点

多线程并不一定比单线程处理的效率高,开启过多的线程,会增加上下文切换的开销,降低了效率。

一、如何降低上下文切换的开销:无锁并发编程、CAS算法、使用最少线程、使用协程

       无锁并发编程:多线程竞争锁会产生额外的上下文切换开销,因此多线程处理数据时尽量减少锁的使用。例如对数据id进行hash算法取模分段,不同的线程处理不同的数据段。

       CAS算法:java.concurrent.atomic包下的原子类使用的就是CAS算法保证数据的原子性。

       使用最少线程:避免创建过多的线程,不需要那么多线程,却创建了许多。

       使用协程:单线程里实现多任度,并在单线程里持多个任务间的切

可以通过jstack查看某进程id下线程的具体情况,也可使用jstack生成线程的dump文件,对线程的具体情况进行分析。

二、Volatile的理解

Volatile保证变量的可见性,即当一个线程修改了某变量的值,其他线程看到该变量的值就是修改以后的。

1、为何能保证变量的可见性???

       操作系统跟数据相关的大致分为处理器,内存,高速缓存区。为了提高速度,处理器一般不直接跟内存进行通信,而是先将系统内存中的数据读取到高速缓存区,然后再做操作。当对变量进行了写操作时,JVM会向处理器发送一条带lock前缀的指令,将这个变量在高速缓存区的数据回写到内存中。由于各处理器之间为了保证缓存一致性,都实现了缓存一致性协议,每个处理器通过嗅探在总线上的数据来检查自己缓存的数据是否失效,当一个处理器将高速缓存区的数据回写到内存中时,其它处理器会发现其缓存区缓存的该数据在内存中的地址发生了改变,于是将该数据在缓存中的地址设置为无效。当对该数据进行再次修改时,会重新从内存中读取数据到缓存区中。

2、多个处理器操作缓存区,最后写入到内存中的是哪个缓存区的数据?

       缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

三、synchronized原理

锁的从低到高依次为:无锁--》偏向锁--》轻量级锁--》重量级锁

1、java对象头

synchronized使用的锁就是在java对象头中。

java对象头包括Mark Word(存储对象的hashCode或者锁的信息)、Class Metadata Address(存储到对象类型数据的指针)、Array Length(存储数组的长度)

若对象是数组,则对象头由以上三部分组成,若对象是非数组,则对象头只由Mark Word、Class Metadata Address组成。

若虚拟机是32位的,则Mark Word、Class Metadata Address、Array Length的长度就是32位的;

若虚拟机是64位的,则Mark Word、Class Metadata Address的长度就是64位的,但是Array Length的长度还是32位。

2、Mark Word

32位的虚拟机中,默认情况下Mark Word中包含2位lock,1位biased lock,4位gc age,25位对象的hashCode

64位的虚拟机中,默认情况下Mark Word中包含2位lock,1位biased lock,4位gc age,31位对象的hashCode,1位cms_free,25位未使用

lock:锁标志位,表示锁的类型,跟biased结合使用,请看下图

biased:偏向锁标识,是否使用了偏向锁,使用了就是1,未使用就是0

gc age:java对象年龄,java对象没经过一次gc,年龄加一,由于gc age占4位二进制,所以gc age最大值为15

在运行期间,Mark Word会随着锁标志位的变化而变化,可能变化存储为以下状态

3、偏向锁

3.1、偏向锁的设计初衷是:大多数情况下锁不存在多线程竞争关系,某同步代码块总是由同一个线程获得并执行。

3.2、偏向锁的竞争升级

      当线程1访问代码块并获取锁对象时,会在对象头以及栈帧中记录偏向的锁的线程ID,以后该线程进入该代码块就不需要使用CAS进行加锁和解锁了。因为偏向锁不会主动释放对象头中的线程的ID,因此当其他线程想要竞争时,会根据对象头中的线程ID查看该线程释放存活,若死亡,则将对象头中该线程ID清空设置成无锁状态,其他线程可通过竞争获取偏向锁。若线程还存活,则会查找该线程的栈帧信息,看是否需要继续持有该锁,若需要,则暂停该线程,撤销偏向锁,将其升级为轻量级锁,然后唤醒该线程。

3.3、关闭偏向锁

关闭偏向锁,偏向锁一般是在应用程序启动以后几秒钟才会激活,可通过参数关闭延迟:-XX:BiasedLockingStartupDelay=0

若确定应用程序中的所有所通常情况下都是竞争的,则可关闭偏向锁::-XX:-UseBiasedLocking=false,程序默认进入轻量级锁状态。

4、轻量级锁

4.1线程竞争,锁升级

线程1访问代码,先在栈帧中分配一块空间用于存储锁记录,然后将Mark Word的内容拷贝到该空间中,这一块空间官方称为Displaced Mark Word,然后开始通过CAS将对象头中的Mark World替换为指向锁记录的指针,若替换成功,则代表获取到了该轻量级锁。这时线程2也开始访问该代码,先在栈帧中分配一块空间用于存储锁记录,然后将Mark Word的内容拷贝到该空间中,然后通过CAS尝试将对象头中的Mark World替换为指向锁记录的指针,但是由于线程1已获得了,线程2失败,于是线程2通过自旋不停的尝试获取。线程1执行完代码块中的代码,准备释放锁,通过CAS将Displaced Mark Word替换回对象头,但是由于线程2还在自旋竞争,于是替换失败,膨胀位重量级锁。线程2由于竞争的轻量级锁升级为重量级锁,线程2阻塞。轻量级锁升级为重量级锁以后线程1释放锁,并唤醒处于阻塞状态的线程2。线程2开始进行新一轮的竞争。

5、三种锁的优缺点

6、处理器如何实现原子操作

通过总线锁保证原子性:总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存

通过缓存锁定来保证原子性:是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效

注:总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化

四、线程的基础

1、线程的优先级值越大,线程越优先,大的优先级高于低的优先级,但是线程的优先级不能作为程序正确性的依赖,因为操作系统可能不理会java程序对于优先级的设置。

2、一个进程中若无非守护线程,则jvm就自动退出了。但是jvm在退出的时候,守护线程的finally块并不一定会执行。因此在构建守护线程的时候,不能依靠finally块的内容来确保执行关闭或清理资源的逻辑。

3、线程的中断可通过interrupt(),线程本身可通过isInterrupted()来判断是否被中断,也可调用静态方法Thread.interrupted()对当前线程的中断标识进行复位。如果该线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的isInterrupted()时依旧会返回false。

从java的API可以看到,许多声明抛出InterruptedException的方法(例如Thread.sleep(long millis)方法),这些方法在抛出InterruptedException之前,java虚拟机会先将该线程的中断标识位清除,然后抛出InterruptedException,此时调用isInterrupted()方法将会返回false。

4、线程的suspend、resume、stop都是不建议使用的,因为使用这几个方法可能会使资源不被释放,依旧处于占用状态,最后导致死锁。例如suspend是在占用资源的情况下进入睡眠的,资源未被释放,而stop则是直接停止线程,没有给资源释放的机会,导致程序可能处于不正确的状态。

5、使用线程的wait(),notify(),notifyAll()方法,需要先对调用对象加锁。调用notify()或notifyAll()方法以后,处于WAITING状态的线程不会获得对象的锁,因为调用notify()或notifyAll()并不会释放对象的锁,需要调用该方法的线程释放锁。

6、notify()方法将等待列中的一个等待线程从等待列中移到同步列中,而notifyAll() 方法则是将等待列中所有的线程全部移到同步列,被移线程状WAITING变为BLOCKED。

7、管道输/出流和普通的文件/出流或者网络输/出流不同之在于,它主要 用于线程之的数据传输,而传输的媒介内存。

管道/出流主要包括了如下4种具体实现PipedOutputStreamPipedInputStream、 PipedReader和PipedWriter,前两种面向字,而后两种面向字符。

五、同步器(AbstractQueuedSychronizer)AQS

1、队列同步器:是用来构建锁和其它同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列完成资源获取线程的排队工作。

同步器只要提供三个方法,getSatate(),setState(int newState),compareAndSetState(int expect,int update),子类通过继承同步器并实现其其抽象方法来实现对同步状态的管理。

2、队列同步器的实现:同步队列、独占式同步状态获取和释放、共享式同步状态获取与释放、超时获取同步状态

2.1当一个线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把队列中首节点中的的线程唤醒,使其再次尝试获取同步状态。

2.2同步队列中节点(Node)用来保存获取同步状态失败线程的引用、等待状态以及前驱和后继节点。

2.3线程获取同步器状态失败,转而被构造成节点并加入同步队列队尾,这个加入队列的过程必须是安全的,因此同步器提供了一个基于CAS的设置尾结点的方法:compareAndSetTail(Node expect,Node update).

设置首节点不需要CAS方法保证而设置尾结点需要CAS保证的原因:设置首节点是根据获取同步状态成功的线程来完成的,只有一个线程能成功获取到同步状态,因此不用CAS保证,而获取状态失败的线程可能有很多,因此需要CAS保证加入尾结点的安全性。

独占式同步状态的获取和释放:在获取同步状态时,同步器会维护一个同步队列,获取状态失败的线程都会被加入到同步队列中并在队列中进行自旋;移出队列或者停止自旋的条件是前驱节点为头结点且成功获取了同步状态。在释放同步状态时,同步器调用tryRealease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

共享式同步状态的获取和释放:在共享式获取自旋的过程中,成功获取到同步状态并推出自旋的条件就是当前节点的前驱节点为头节点,当前节点尝试获取同步状态,使用tryAcquireShared(int arg)方法返回值大于等于0。跟独占式不同之处在于,共享式要确保同步状态的释放是安全的,一般通过循环和CAS,因为同步状态的释放操作可能来自多个线程。

独占式超时获取同步状态:独占式超时获取同步状doAcquireNanos(int arg,long nanosTimeout)和独占式获取同步状acquire(int args)在流程上非常相似,其主要区在于未取到同步状态时的逻辑acquire(int args)在未取到同步状态时,将会使当前线程一直于等待状态,而doAcquireNanos(int arg,long nanosTimeout)会使当前线程等待nanosTimeout秒,如果当前线程在nanosTimeout秒内没有取到同步状,将会从等待逻辑中自返回。

六、Java中的锁

1、lock接口,其实现类可以手动加锁,然后手动释放锁。但是注意在finally模块释放锁,保证锁能被释放。但是获取锁的过程不能放在try中,要放在try外,不然在获取锁的过程中如果发生了异常,异常抛出的同时,也会导致锁无故释放。

2、重入锁(ReentrantLock、Sychronized)

支持重进入的锁,它表示该锁能过支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平性和非公平性的选择。

重进入和释放:ReentrantLock是通过组合自定义同步器来实现锁的获取和释放。ReentrantLock内部获取同步状态的判断没变,但是多了一个步骤,当同一个线程再次获取同步状态时,判断该线程是否跟已获取同步状态的线程是一个线程,若是同一个线程,则同步状态加一,并返回true,代表获取同步状态成功。当该线程释放时,如果该锁被获取了n次,那么前n-1次释放方法必须返回false,而只有同步状态都释放了才返回true。当同步状态返回为0时,将占有线程设置为null,并返回为true,代表释放成功。

公平和非公平获取锁:公平性与否是针对锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。公平性获取锁和非公平性获取锁的区别是:获取锁的时候多了一个判断方法,判断加入同步队列中该节点是否有前驱节点,如果返回true,则代表有前驱节点,只能等前驱节点获取同步状态并释放以后,当前节点才能继续获取锁。

公平性获取锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程"饥饿",但极少的线程切换,保证了更大的吞吐量。

3、读写锁

3.1、一般情况下读写锁的性能都比排它锁的性能好,因为大多数场景都是读多于写。java并发包提供的读写锁的实现是ReentrantReadWriteLock。该锁支持:公平性选择、重进入、锁的降级(写锁降级为读锁)。

3.2、读写锁的实现设计

定义一个整型的变量来维护多个读线程和一个写线程的状态。32位的整型变量,高16位表示读,低16表示写。

写锁是一个支持重入的排它锁。读锁是一个支持重入的共享锁。

3.3、锁的降级

若当前线程已获取了写锁,然后再获取到读锁,然后释放写锁,则成为锁的降级。

3.4、锁的升级

若当前线程已经获得了读锁,然后再获取写锁,然后释放读锁,则成为锁的升级。

ReentrantReadWriteLock支持锁的降级,但不支持锁的升级。目的是保证数据可见性,多个线程同时获取了读锁,若其中一个线程又获得了写锁并更新了数据,这些数据的更新对那些获得读锁的线程不可见。

4、LockSupport工具

当需要阻塞或者唤醒线程的时候都需要LockSupport工具,其park()和unpark()方法,jdk1.6以后在parkNanos(long nanos)中增加了一个blocker参数,用来标识当前线程正在等待的对象。jdk1.6以及以后变成parkNanos(Object blocker,long nanos)。

5、condition

任何一个java对象,都有一组监视器方法(定义在Object上),主要包括wait(),wait(long timeout),notify(),notifyAll(),这些方法跟sychronized关键字配合,可以实现等待/通知模式。而Condition接口提供了跟Object类似的监视器方法,与Lock配合实现等待/通知模式。Object监视器拥有一个同步队列一个等待队列,而lock则拥有一个同步队列和多个等待队列。

Lock lock = new ReentrantLock

Conditon conditon = lock.newCondition()

ConditionObject是同步器AbstractQueuedSychronizer的内部类,因为Condition的操作需要获取相关的锁。每个Condition对象都包含一个队列,该队列是Condition对象实现等待/通知功能的关键。如果调用condition.await()方法,则必须先获取锁。

进入等待状态:同步队列的首节点获取到锁以后,如果调用了condition.await()方法,则该首节点的线程被构建成一个新的节点进入等待队列中,然后释放同步状态,唤醒同步队列中的后继节点,同时当前线程会进入等待状态。

通知:另一个线程获取到了锁,然后调用conditon.signal()方法,然后获取等待队列中的首节点,然后将其移动到同步队列的尾结点,然后使用LockSupport唤醒该节点中的线程,被唤醒的线程将会从await()方法中的while循环中退出,进而调用同步器的acquireQueued()方法加入到获取同步状态的竞争中去。若成功获取同步之后,被唤醒的线程将会从之前调用的await()方法返回,此时该线程已经成功获取到了锁。

condition.signalAll()方法,相当于同步队列中的每个节点均执行一次signal方法,效果就是等待队列中的所有节点全部移动到同步队列中,并唤醒每个节点的线程。

七、Java并发容器和框架

1、ConcurrentHashMap

1.1、多线程情况下,使用hashMap的put会导致死循环,导致CPU利用率接近100%,因为多线程会导致hashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。

产生的原因:例如有一个只有2个容量的数组,在1位置存放了3,7,5,这个时候新加入数据发现达到了阈值,就得扩容,扩容以后是4,这里位置的算法按照取模来算,则3和7都对应下标为3的地方,5对应下标为1的地方,但是如果这个时候多线程扩容将数据重新排放,则线程一先把3放进去,然后再把7放到3的前面,现在就是7-3,然后线程二拿到了CPU,开始执行,它会开始重排,结果3放在了首位,也就是3-7-3,然后再放7,就变成了7-3-7-3,其实就变成了7《--》3,产生了死循环。

1.2、hashMap和ConcurrentHashMap扩容

hashMap是在数据插入以后判断是否达到了阈值,如果达到了就数据扩容两倍,如果扩容后没有数据插入,则就进行了一次无效的插入。但是ConcurrentHashMap就不一样,它是在插入之前判断Segment里的HashEntry数组是否达到阈值,如果达到了就进行扩容,但为了高效,ConcurrentHashMap不会对整个容器扩容,而只是对某个segment进行扩容。

1.3、统计ConcurrentHashMap里元素的大小

统计每个Segment里元素的大小,然后求和。但是如果统计每个Segment的元素大小count时,某个Segment对元素进行了操作,那么就不准确了,但是对每个Segment加锁然后统计,会导致低效。ConcurrentHashMap默认认为对元素个数统计相加的时候,之前累加的count变化的概率非常小的。ConcurrentHashMap先尝试2次通过不加锁的方式统计大小,如果统计过程中count发生了变化,则再使用加锁统计。至于如何判断发生了变化,其内部有一个modCount属性,每次put,remove,clean操作,modCount都会加一,统计count前后对比modCount是否发生了变化即可。

2、阻塞队列(BlockingQueue)

2.1、当队列满的时候,队列会阻塞插入元素的线程,直到队列不满。当队列为空时,获取元素的线程会被阻塞,直到队列非空。

一直阻塞的方法:put,take     超时阻塞的方法:offer,poll

2.2、java中的阻塞队列

2.2.1、ArrayBlockingQueue

一个用数组实现的有界阻塞队列,按照FIFO对元素进行排序。默认情况下不保证线程公平的访问队列。但是可使用重入锁保证线程访问的公平性。

2.2.2、LinkedBlockingQueue

一个用链表实现的有界阻塞队列,按照FIFO对元素进行排序。此队列默认的和最大长度为Integer.MAX_VALUE。

2.2.3、PriorityBlockingQueue

一个支持优先级的无界阻塞队列,默认情况下按照自然顺序升序排列。

2.3.4、DelayQueue 

一个支持延时获取元素的无界阻塞队列。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久能从队列中获取元素,只有在延迟期满才能从队列中提取元素。

2.3.5、SynchronousQueue

一个不存储元素的阻塞队列,每一个put操作必须等待一个take操作,否则不能继续添加元素。默认情况下线程使用非公平的策略访问队列。其吞吐量高于LinkedBlockingQueue和ArrayBlockingQueue。

2.3.6、LinkedTransferQueue

一个由链表结构组成的无界阻塞队列。其多了transfer和tryTransfer方法

如果有消费者等待消费,则transfer方法会将生产者生产的消息直接发送给消费者。如果没有生产者,则transfer会将元素存放在队列的tail节点,然后等待元素被生产者消费以后再返回。

tryTransfer方法试探生产者生产的消息能否直接发送给消费者。如果没有消费者等待消费,则直接返回false。tryTransfer是无论是否有消费者消费均直接返回,而不像transfer等待再返回。

tryTransfer(E e,long timeout,TimeUnit unit)方法试图把生产者传入的元素直接给消费者,如果没有消费者消费则等待指定时间以后再返回。如果超时元素还没被消费,则返回false,如果超时时间内消费了元素,则返回true。

2.3.7、LinkedBlockingDeque

一个有链表结构组成的双向阻塞队列。

3、Fork/Join框架

3.1、Fork/Join框架是java 7提供的一个用于执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务的结果最后得到大任务结果的框架。

3.2、工作窃取算法:大任务分为n多个小任务,为了减少线程间的竞争,把不同的任务放到不同的队列中,并为每个队列创建一个线程。当一个队列中的线程完成了任务,而其他的队列还未完成,则其去其他队列帮忙,为了防止冲突,因此使用的是双端队列,被窃取放从队列头拿任务,窃取方从队列尾拿任务。

优点:充分利用线程进行计算,减少了线程间的竞争;‘缺点:某些情况下存在竞争。例如双端队列只有一个任务、该算法会消耗更多的系统资源,比如创建多个线程和多个双端队列。

3.3、Fork/Join框架的设置

创建ForkJoin任务,不需要继承ForkJoinTask类,继承其子类RecursiveAction:用于没有返回结果的任务或RecursiveTask:用于有返回结果的任务,这个两个子类都有fork(),join()方法。然后用ForkJoinPool执行ForkJoinTask。

八、Java中13个原子类操作

JDK1.5开始提供了java.util.concurrent.atomic包,该包里的类基本都是使用Unsafe实现的包装类。分为原子更新基本类型、原子更新数组、原子更新引用和原子更新属性。

1、原子更新基本类型

AtomicBoolean、AtomicInteger、AtomicLong

都是先拿到当前的值,然后使用unsafe.compareAndSet()方法,对比拿到的当前的值跟要加的值是否一样,一样则加一,如果不一样则for循环进行compareAndSet()。

unsafe只提供了三种CAS方法,CompareAndSwapObject、CompareAndSwapInt、CompareAndSwapLong。通过看AtomicInBooelan的源码,发现是先把Boolean转换成整型,再使用CompoareAndSwapInt进行CAS,所以原子更新char,float,double也可以用类似的思路实现。

2、原子更新数组`

AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray、AtomicInteger

注意:数组通过构造方法传递进原子更新数组,然后原子更新数组会将传入的数组复制一份,所以当原子更新数组对内部的数组进行操作时,不会影响传入数组的原数据。

3、原子跟更新引用类型

AtomicReference:原子更新引用型;AtomicReferenceFieldUpdate:原子更新引用型里的字段;AtomicMarkableReference:原子更新标记位的引用型。

可以原子更新一个布类型的标记位和引用型。构造方法是AtomicMarkableReferenceV initialRefboolean initialMark)。

4、原子更新字段类:如果需原子地更新某个里的某个字段,就需要使用原子更新字段

AtomicIntegerFieldUpdate:原子更新整型的字段的更新器;AtomicLongFieldUpdate:原子更新整型字段的更新器;AutomicStampedReference:原子更新有版本号的引用

要想原子地更新字段类需要两步:一、使用静方法newUpdater()建一个更新器,并且需要置想要更新的类和属性;二、更新的字段(属性)必使用public volatile

九、Java中的并发工具类

 1、CountDownLatch:允许一个或者多个线程等待其他线程完成操作。

CountDownLatch可以实现join()的功能,并且比join()的功能更多。CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,就传入N。当我们调用一次countDown()方法,N就减一,CountDownLatch的await方法会阻塞当前进程,直到N变成0。

2、CyclicBarrier(同步屏障):让一组线程达到一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。

CyclicBarrier的默认构造方法CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier我已经到达了屏障,然后阻塞当前线程。

CyclicBarrier还提供了了一个更高级的构造函数CyclicBarrier(int parties,Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景。

CyclicBarrier和CountDownLatch的区别:CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置。

3、Semaphore:控制并发线程数

Semaphore的构造方法Semaphore(int permits)接收一个整型的数字,表示可用的许可证的数量。Semaphore(10)代表允许10个线程获得许可证,也是就是最大并发数为10。

用法:首先线程使用Semaphore的acquire()方法接收一个许可证,然后使用完成以后调用release()方法归还许可证。还可以使用tryAcquire()方法尝试获取许可证。

4、Exchanger:线程间交换数据

Exchanger提供一个同步点,在这个同步点两个线程可以互相交换数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange方法,当两个线程都到达同步点,这个两个线程就可以交换自己的数据,将本线程生产出来的数据传递给对方。

十、Java中的线程池

1、使用线程池的好处:1、降低资源消耗,通过重复利用已创建的线程降低线程创建和销毁造成的消耗;2、提高响应速度,一旦有任务可以立马调用线程池空闲的线程,不用再重新创建;3、提高线程的可管理性,线程池可以对线程进行统一化管理,降低资源的消耗,降低系统的稳定性。

2、线程池的工作流程:(1)当线程池接收到一个任务的时候,先判断执行任务的线程数是否小于核心线程数,如果是,则新创建一个线程来执行该任务,如果不是,进入下一个流程;(2)判断任务队列是否满了,如果不是,将该任务放在任务队列中,等待核心线程完成任务释放,如果是,则进入下一个流程;(3)查看当前执行任务的线程是否到达最大线程数,如果没有,则新创建一个线程来执行,如果等于最大线程数了,则根据饱和策略来针对饱和任务进行操作。

3、线程池的几个参数:核心线程数,最大线程数(如果使用了无界队列,该参数就无用了),线程活动保持时间(线程空闲时保持存活的时间),任务队列,饱和策略(默认的是AbortPolicy,抛出异常)。

4、任务队列

ArrayBlockingQueue队列,FIFO,有界队列;

LinkedBlockingQueue队列,FIFO,有界队列,吞吐量比LinkedBlockingQueue,是Executors.newFixedThreadPool()使用的队列;

SynchronousQueue队列,不存储元素的队列,一个元素的插入必须等另一个线程调用移出操作,否则插入一直处于阻塞状态,效率高于LinkedBlockingQueue队列,是Executors.newCachedThreadPool()使用的队列。

PriorityBlockingQueue:一个具有优先级的无限阻塞队列

5、饱和策略

AbortPolicy:抛出异常(默认策略);CallerRunsPolicy:只用调用者所在的线程来执行任务;DiscardOldestPolicy:丢弃队列里最近的一个任务,执行当前任务;DiscardPolicy:不处理,丢弃掉。

当然,也可以根据需求来实现RejectedExecutionHandler接口来自定义策略,例如记录日志或者持久化存储不能处理的任务。

6、任务的提交

向线程池提交任务,有两个方法execute()和submit()方法。

execute()方法用于提交不需要返回值的任务,因此不知道任务是否被成功执行。

submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过future对象可以判断任务是否执行成功,并且可以通过future的get()方法来回去返回值,get()方法会阻塞当前线程直到任务完成。

7、关闭线程池

使用shutdown或者shutdownNow方法,都是遍历工作线程,然后逐个调用线程的interrupt()中断线程,所以无法响应中断的任务可能永远无法停止。

shutdown将线程池的状态设置为SHUTDOWN状态,然后中断所有没有正在执行任务的线程。

shutdownNow将线程池的状态设置为STOP,然后尝试停止所有正在执行或者暂停任务的线程,并返回等待执行任务列表。

因此,调用shutdown以后并不是所有的线程都关闭了,正在执行的会执行完才关闭,如果想无论是否执行都关闭,则用shutdownNow。

两个方法调用任意一个,isShutdown方法都会返回true,只有当所有的任务都关闭了,isTerminated才会返回true。

十一、Executor框架

1、Executor框架的结构

任务:被执行任务需要实现Runnable接口或者Callable接口

任务的执行:任务执行的核心接口Executor和继承自Executor的ExecutorService接口。Executor框架有两个实现类实现了ExecutorService,它们分别是ThreadPoolExecutor和ScheduledThreadPoolExecutor。

异步计算的结果:接口Future以及它的实现类FutureTask,有get()方法获取任务执行的结果,有cancel()取消提交给线程池的的任务。

可以将实现Runnable的类通过工具类Executors.callable(Runnable task)或Executors.callable(Runnable task,Object resule)封装成Callable对象

2、Executor架构的成员

ThreadPoolExecutor:SingleThrealExecutor、FixedThreadPool(适用于需要保证顺序地执行各个任务,并且在多个时间点不会有多个线程)、CachedThreadPool(大小无界的线程池)

ScheduledThreadPoolExecutor(定期执行任务):ScheduledThreadPoolExecutor(包含多个线程)、SingleThreadScheduledExecutor(只包含一个线程)

注意:并不是调用了executor.sumit()就能返回FutureTask,然后通过get方法获取结果,而是实现Callable接口的类才能返回值,实现Runnable接口的类,调用的如果不是带有返回结果的sumbit方法,FutureTask.get()将返回null。同理,通过Executors工具类的callable方法将实现Runnable接口的类包装成一个Callable,如果调用的方法不带返回结果的,就算调用了submit方法,然后FutureTask.get()也是null。

3、ScheduledThreadPoolExecutor

3.1、任务的执行过程

3.1.1、当调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()或者shceduleWithFixedDelay()方法时,会向ScheduledThreadPoolExecutor的DelayQueue队列中添加一个实现了RunnableScheduledFuture接口的ScheduledFutureTask类。

3.1.2、线程池中的线程从DelayQueue中获取ScheduledFutureTask,然后执行任务

3.1.3、任务执行完成,修改任务的time,即下次执行的时间,然后将该任务放回DelayQueu队列中

ScheduledFutureTask有三个变量,time,sequenceNumber,period,三者都是long类型的,其中time代表任务执行的具体时间,sequenceNumber代表任务加入队列的序号,period代表任务执行的间隔周期。

其实DelayQueue队列封装了PriorityQueue,PriorityQueue是一个基于优先级的队列,其根据time大小进行排序,time小的排在前面,时间早的任务先执行,如果时间一致,则再根据sequenceNumber排序,sequenceNumber小的排在前面,也就是先提交的任务先被执行。

3.2、获取任务

3.2.1、获取Lock锁;

3.2.2、获取周期任务

如果PriorityQueue为空,则将线程放到Condition中等待,若不为空,则执行下一步;

如果PriorityQueue的头元素的time大于当前时间,则将线程放到Condition中等待到time的时间,否则执行下一步;

获取PriorityQueue的头元素,如果不为空,且其time不大于当前时间,则唤醒Conditon中等待的所有线程。

3.2.3、释放Lock锁

4、FutureTask

FutureTask即实现了Task接口,又实现了Runnable接口。因此可以直接调用FutueTask.run()方法启动FutureTask。

当FutureTask未启动或已处于启动状态,执行futureTask.get()将会阻塞调用线程,当FutureTask处于已完成状态时,执行FutureTask.get()方法将导致调用线程立即返回结果或者直接抛出异常。

FutureTask.cancel()会使未执行的任务永远不会执行,FutureTask.cancel(true)会中断正在执行的任务,FutureTask.cancel(false)不会对正在执行的线程产生影响,会让正在执行的任务执行完成。FutureTask.cancel()会会在FutureTask处于已完成时返回false。

 十二、JAVA内存模型(JMM)

1、java并发采用共享的内存模型,java线程间的通信总是隐式的进行。

2、由于堆内存和方法区是线程共享的,因此这些共享变量可能会产生内存可见性问题。局部变量、方法定义的参数、异常处理器参数都属于线程私有的,不会有内存可见性问题,也不受内存模型的影响。

3、java内存模型控制线程之间的通信。java内存模型定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,用来存储该线程以读/写共享变量的副本。

4、指令重排

为了提高效率,编译器和处理器常常会对指令进行重排序。重排序分为三种类型,编译器优化的重排序、指令级并行的重排序、内存系统的重排序。其中第一个为编译器重排序,后两个属于处理器重排序。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

注:编译器和处理器不会改变存在数据依赖关系的两个操作的顺序。这里仅指单个处理器或者单个线程内的操作。

5、内存屏障分为LoadLoad Barriers、StoreStore Barriers、LoadStore Barriers、StoreLoad Barriers。

其中StoreLoad Barriers是一个全能型的屏障,它同时具有其它三个屏障的效果。现在大多数处理器都支持该屏障,但是该屏障的开销很昂贵,因为当前处理器通常要把写缓存区的全部数据都刷新到内存中。

6、happens-before

在JMM中,如果一个操作执行的结果对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这两个操作可以在同一个线程也可以在不同的线程中。

常见的happens-before:锁的解锁happens-before锁的加锁;一个对volatile的写happens-before后续对这个volatile的读。

注意:两个操作具有happens-before并不意味着前一个操作必须要在后一个操作之前执行。happens-before是指前一个操作的结果对后一个可见,且前一个操作按顺序排在第二个操作之前。

其实对于程序员来讲,happens-before简单易懂,它避免程序员为了理解JMM提供的内存可见性保证而学习复杂的重排序规则以及规则的具体方法。其实一个happens-before规则对应于一个或多个编译器或者处理器重排序规则,但是JMM已经帮我们把一些编译器和处理器的重排进行了禁止。

7、as-if-serial:不管怎么重排序,单线程的程序的执行结果不能被改变。

编译器、runtime、处理器都必须遵守as-if-serial语义。为了遵守该语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因此给人的感觉是单线程的程序是按照程序顺序执行的,但是有可能是发生了改变,但是结果一样而已。

8、软硬件开发者的共同目标:在不改变程序执行结果的前提下,尽可能提高并行度。

9、顺序一致性模型

JMM对正确同步的多线程程序的内存一致性做出了保证:如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果一致。

顺序一致性模型:只有一个单一的全局内存,这个内存通过左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。

10、volatile的内存语义

volatile写:JMM将本地内存中的共享变量的值刷新到主内存。

volatile读:JMM将本地内存中的设置为无效,然后从主内存中读取共享变量。

从内存语义的角度来说,volatile的读-写与锁的释放有相同的内存效果:volatile写和锁的释放有相同的内存语义,volatile的读和锁的获取有相同的语义。

线程A写了一个volatile变量,随后线程B读取这个volatile变量,这个过程实质就是线程A通过主内存向线程B发送消息。

为了保证volatile读/写内存语义的实现,JMM对于编译器指令重排和处理器重排都做了限制,在编译器方面制定了volatile重排序规则表,在处理器方面加入了内存屏障。

        其中对于编译器,当第二个操作时volatile写时,无论第一个是什么操作,都不能重排;对于第一个操作时读时,无论是第二个操作是什么,都不能重排序。

        对于处理器来讲,在每个volatile写之前插入StoreStore屏障,在volatile写之后插入StoreLoad屏障;在每个volatile读之后插入一个LoadLoad屏障,在LoadLoad之后再插入一个LoadStore屏障。

11、锁的内存语义

线锁时JMM会把该线对应的本地内存中的共享量刷新到主内存中。

线锁时JMM会把该线对应的本地内存置无效。从而使得被监视器保的临界区代从主内存中取共享量。

锁内存语义的实现:公平锁和非公平锁释放时,都要写一个volatile变量state。公平锁获取时,首先会去读volatile变量。非公平锁获取时,首先使用CAS更新volatile,这个操作同时具有volatile读和volatile写的内存语义。

总结:锁的获取和释放的内存语义的实现至少有以下两种方式:利用volatile变量的读-写所具有的语义;利用CAS附带的volatile读和volatile写的内存语义。

为什么说CAS同时具有volatile读和volatile写的内存语义?

从编译器方面看,编译器不会对volatile读和读之后、volatile写和写之前的任意内存操作重排序,而编译器不能对CAS与CAS前面和后面的任意内存操作重排序,因此编译器方面说明了。

再从处理器方面看,CAS的源码是使用了lock前缀指令,处理器禁止该指令,与之前和之后的读写指令重排序;处理器把写缓存区中的数据都刷新到内存中,具有volatile读写同样的效果。因此处理器方面也说明了。

12、关于happens-before,JMM对程序员的保证是:如果A happens-before B,则A操作的结果对B可见,且A的执行顺序在B之前,但这只是对程序员的保证。

对于编译器和处理器而言:只要不改变程序执行的的结果,编译器和处理器怎么优化都行,A的执行顺序不一定在B之前。

13、单纯的双重检查出现的问题以及解决方案

public class DoubleCheckedLocking { 
    private static Instance instance; 
    public static Instance getInstance() { 
        if (instance == null) { 
            synchronized (DoubleCheckedLocking.class){
                if (instance == null) 
                    instance = new Instance(); 
            } 
        } 
    return instance; 
    } 
}

出现问题的地方: instance = new Instance(); 这个步骤是分配对象的内存空间,然后初始化对象,再讲instance指向刚分配的内存地址。但是这个地方会发生指令重排,会先分配对象的内存空间,然后instance指向刚分配的地址,然后再初始化。因为能保证单线程内结果不变,所以指令重排是允许的,但是对于多线程的并发,可能另一个线程访问了第一个if(instance == null),发现instance不为null,然后就走到renturn instance,访问instance。但是这时A线程还未初始化该对象,则B将会访问一个未初始化的对象。

解决方案:1、使用volatile,volatile会禁止在多线程的情况下该初始化的指令重排,这样对象不为null的时候一定初始化了。

2、使用静态内部类,静态内部类在被调用的时候会初始化,同时会初始化该静态类的静态资源。原因:在类的初始化期间,JVM会获取一个锁,保证多个线程同步对一个类的初始化。

类初始化的条件:

1、T是一个类,而且一个T类型的实例被创建。2、T是一个类,且T中声明的一个静态方法被调用。3、T中声明的一个静态字段被赋值。4、T中声明一个静态字段被使用,而且这个字段不是常量字段。5、T是一个顶级类,而且一个断言语句嵌套在T内部被执行。

14、JMM总结

14.1、不同处理器的内存模型不能,为了给程序员呈现一个一致的内存模型,JMM通过给不同的处理器插入不同的内存屏障来屏蔽不同处理器内存模型的差异。

14.2、各内存模型之间的关系(JMM、处理器内存模型、顺序一致性内存模型)

JMM是语言级内存模型,处理区内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。

处理器内存模型都比语言级的内存模型弱,处理器和语言级内存模型都比顺序一致性内存模型弱。跟处理器模型一样,越是追求性能的语言,内存模型就会被设计的越弱。

41.3、JMM的内存可见性保证

对於单线程程序,不会出现内存可见性问题,JMM保证其处理结果跟在顺序一致性模型内的结果一致。

对于正确同步的多线程程序,也不会出现内存可见性问题。JMM通过限制编译器和处理器的重排序来提供保证。

对于未同步或者未正确同步的多线程程序。JMM提供了最小的安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值。

十三、总结

1、线程并非使用的越多越好,在资源限定的情况下,使用过多的线程可能会更慢,因为上下文切换会耗费资源。当一个线程拿到CPU时间片,然后运行一段时间以后,另一个线程获得了CPU时间片,该线程的运行状态就会保存下来,以便再次获得CPU时间片的时候可以再加载这个状态,这个线程从保存到再加载的过程就是一次上下文切换的过程。

2、为了减少上下文切换带来的资源浪费,可以用以下三个方法: 使用无锁并发编程、使用CAS算法、使用最少线程、使用协程。无锁并发编程:一堆任务,可以根据取模算法,将数据根据id分为不同的线程,不用的线程执行自己负责的数据范围。

3、volatile能保证可见性,无法保证原子性。何为可见性:当一个线程修改了共享变量,另一个线程能读到这个修改的值。

volatile怎么保证数据的可见性的?多个处理器共享一个变量,会将变量在内存中的地址存储到处理器自己的高速缓存区(L1L2L3),当给变量加上volatile时,一个处理器对变量进行修改时,会将修改的数据直接回写到内存中,为了保证多处理器的缓存一致,就会实现缓存一致性,每个处理器会通过嗅探总线上的数据来感知自己的数据是否过期了,当处理器发现自己缓存行对应的内存地址被修改了,就会将缓存行设置为失效。当对这个数据进行再操作时,会重新从内存中把数据读到处理器的缓存中。

4、处理器如何保证操作的原子性的?两个方法:总线锁、缓存锁。但是总线锁会将CPU与内存之间的通信锁住,锁定期间,其他处理器不能操作其他内存地址的数据,因此开销比较大,一般都使用缓存锁,不同的处理器使用的不一样,根据情况而定。

总线锁:处理器提供一个LOCK#信号,当一个处理器在总线上输出次信号时,其他处理器的请求会被阻塞,那么处理器会独占共享内存。

缓存锁:指内存区域如果被缓存在理器的存行中,并且在Lock操作期定,那么当它操作回写到内存理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允它的存一致性机制来保操作的原子性,因为缓存一致性机制会阻止同修改由两个以上理器存的内存区域数据,当其他处理器回写已被锁定的存行的数据,会使存行无效。

5、sychronized锁

锁在普通方法上,其实就是锁了实例,如果锁在Classs和静态方法上,其实就是锁对象

6、锁的信息存储在java对象头中。如果对象是非数组类型的数据,那么虚拟机就用3个字宽存储对象头。如果对象是数组类型的数据,那么虚拟机就用2个字宽存储对象头。在32位虚拟机中,一个字宽=4字节(byte),也就是32(位)bit,在64位虚拟机中,一个字宽=8字节,也就是64位。

7、对象头中的字宽,一个字宽用来存储数组的长度,一个字宽用来存储到对象类型数据的指针,另一个字宽就是常用的MarkWord,用来存储对象的hashcode值、分代年龄、锁的信息(锁状态,是否偏向锁,锁的标志位)。

8、锁的升级

锁可以升级,但不能降级,一旦升级为上一层级的锁,无法再降级为下一层级的锁。

无锁--》偏向锁--》轻量级锁--》重量级锁

偏向锁:当一个线程获取偏向锁的时候,会尝试使用CAS在栈帧和mark word中记录锁偏向该线程的线程ID,如果记录成功了,则代表获取了锁。偏向锁不会自己释放,只有当发生了竞争,才会执行锁释放的过程。当一个线程持有了偏向锁,另一个线程尝试获取该锁的时候,它会先暂停持有偏向锁的线程,然后查看持有偏向锁的线程是否存活,若不存活,则将对象头设置为无锁状态。若持有线程依然存活,则要么将该锁重新偏向该线程,要么将对象设置为无锁状态,然后唤醒暂停的状态。

注:偏向锁的释放需要等待全局安全点,也就是这个时间点上没有正在执行的字节码。

优:加锁解锁不需要额外的消耗;缺:锁竞争的情况下,锁的撤销会带来额外的消耗

轻量级锁:当一个线程尝试获取轻量级锁时,会将mark word复制一份到其栈帧的空间中,然后使用CAS尝试将对象头中的mark word替换成执行其栈帧的指针,若替换成功,则代表获取了锁,若失败,则自旋继续尝试替换。当执行完同步方法时,释放锁的时候,其会尝试将栈帧中的mark word替换到对象头中,这时,如果其它的线程来竞争锁,那么该轻量级锁会升级为重量级锁,并阻塞竞争线程。

优:竞争的线程不会阻塞,提高了程序的相应速度。缺:如果始终获取不到锁的线程,自旋会消耗CPU。

重量级锁:阻塞锁

优:线程竞争不使用自旋,不会消耗CPU。缺:线程阻塞,响应时间慢。

9、队列同步器

锁的实现是调用同步器提供的模板方法,然后来完成相关锁的特性。

独占式的:当多个线程调用同步器的acquire方法试图获取同步器同步状态,同一个时间只能有一个获取成功个,获取失败的线程会被构建成一个节点加入到同步队列中去,通过CAS(因为失败的可能很多)的方式加入队列的尾部,自旋同时会阻塞该线程,然后同步器的首节点指向同步队列中的首节点,尾结点指向同步队列中的最后一个。当同步状态释放时,会把首节点中的线程唤醒,使其尝试获取同步状态。当头节点释放同步状态以后,会唤醒后继节点,后继节点的线程被唤醒以后检查自己前驱节点是不是头节点,如果是的话则尝试获取同步状态。

        停止自旋或者移出队列的条件是:前驱节点为头节点且成功获取了同步状态。

        线程被唤醒的情况:前驱节点为头节点,释放同步状态而唤醒后继节点的线程;线程由于被中断而被唤醒,这个时候会立刻返回并抛出InterruptedException。

        自旋:自我检查,看自己是否满足尝试获取同步状态的条件,如果满足,则尝试获取。满足尝试的条件:前驱节点为头节点。

共享式的:共享式跟独占式的区别是在同步状态释放的时候,因为同步状态的释放操作可能来自多个线程,因此为了保证安全一般都是通过循环和CAS保证的。

独占式超时获取同步状态:与独占式获取同步状态的区别在于当未获取到同步状态时,则会使当前线程等待n纳秒,如果当前线程在n纳秒没有获取到同步状态,将会从等待逻辑中自动返回。

10、可重入锁

Sychronized和ReentrantLock,ReentrantReadWriteLock都是可重入的,其都实现了队列同步器,当获取到锁的线程再次获取锁的时候,同步状态加一,返回true,当释放锁的时候,同步状态减一,当什么时候同步状态减到0,则代表完全释放才能返回true,否则之前都是返回false。

公平锁与非公平锁的获取:不是通过CAS设置同步状态成功即可,而是加了一个判断条件,即同步队列中当前节点是否有前驱节点的判断,若有则代表有线程比当前线程更早的请求获取锁,因此需要等待前驱节点获取并释放锁以后才能继续获取锁。

11、读写锁

用一个整型变量维护多个读线程和一个写线程的状态,低16位表示写,高16位表示读。

锁的降级:当前线程先获取写锁,然后获取读锁,然后再释放写锁的过程。

不能有锁的升级,即当前线程先获取读锁,然后再获取写锁,然后释放读锁。因为保证数据的可见性,若多个线程获取了读锁,该线程进行了锁的升级,修改了变量的内容,其它线程无法感知。

12、Condition

Condition对象是由Lock对象获取出来的,通过Lock.newConditon()获取。只有获取了锁,才能调用conditon的await等方法。

ConditionObject是同步器(AQS)的内部类。ConditionObject有一个等待队列来实现其等待通知的功能,跟同步器的同步队列一样,一旦调用conditon的await方法以后,线程就会释放锁,将当前线程重新构建成一个新节点加入等待队列的尾结点,调用condition的signal方法,将会将等待队列中的首节点先移动到同步队列的尾结点,然后调用LockSupport的方法将该节点中的线程唤醒,当唤醒后再次获取同步状态成功以后,才会从先前调用的await()方法中返回true。

在Object的监视器模型上,一个对象拥有一个同步队列和一个等待队列。而同步器则拥有一个同步队列和多个等待队列,因为它本身有一个同步队列,它的内部类ConditinObject有等待队列,可以创建多个,因此其有一个同步队列,对个等待队列。

13、CountDownLatch和CyclicBarrier(同步屏障)、Semaphore

两者都是让一个或者多个线程等待其它线程完成操作,都是在初始化的时候赋数字,然后每次调用一次计数减一,然后等待数字减为0以后才能继续走。都是使用await()阻塞当前线程。

不同之处:CountDownLatch的计数只能用一次,不可重置,而CyclicBarrier的计数可以使用以后再重置。

使用方式不同:countDownLatch.countDown();调用一定次数以后调用它的await()方法,而CyclicBarrier是直接调用await()方法一定的次数。

Semaphore:控制并发量。就算线程是100个,如果我使用Semaphore初始化的时候就初始化10个,最大并发也就是10。

14、JMM是用来描述多线程和内存之间通信的问题,而jvm解决的是对象内存自动管理的问题。

15、8个原子操作:加锁(lock)、解锁(unlock)、读取(read)、载入(load)、使用(user)、赋值(assign)、存储(store)、写入(write)

16、缓存锁定,若出现两个同时锁定,怎么办,总线会裁决。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章