java并发编程实战笔记

1、复合操作

若一个类里有多个属性状态,对每个属性使用atomic类修饰,并且一个属性更新,要在同一原子操作内更新其他所有属性,这样才是线程安全类。需要整体类的状态操作是原子的。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

 

判断同步代码块的合理大小,要权衡安全性、简单性和性能。

 

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

 

 

2、对象的共享

1)可见性

为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。

volatile,变量被volatile声明后不会被重排,不会被缓存,都是返回最新值。

使用场景:仅当volatile变量能简化代码实现以及对同步策略的验证时,才使用。

如:确保变量自身状态是可见的;

确保他们所引用的对象的状态是可见性的;

标识一些重要的程序生命周期事件:如初始化或关闭。

 

Volatile通常被用做某个操作完成、发生中断或状态的标识。

 

使用volatile保证线程安全的应满足的条件:

只有一个线程更新变量的值

该变量不与其他状态变量一起纳入不变性条件中

访问该变量是不需要加锁

 

2)对象发布

使对象能够在当前作用域以外的代码中使用。

 

任何类和线程都能看到knownSecrets,即发布。

 

 

私有变量statesgetStates()逸出(被发布到当前作用域外)。

 

使用封装的意义:封装能够使得对程序的正确性进行分析变成可能(逸出变数太大),并使无意中破坏设计约束条件变得更难。

 

不可变对象的条件:

对象创建后其状态就不可修改;

对象的所有域都是final类型;

对象是正确创建的:对象创建期间,this引用没有逸出。

 

 

Object的构造函数会在子类构造函数运行之前先将默认值写入所有的域。因此,多线程时某个域的值可能为失效值。

 

安全发布一个正确构造对象的方式:

在静态初始化函数中初始化一个对象的引用。

将对象的引用保存到volatile类型的域或者AtomicReferance对象中。

将对象的引用保存到某个正确构造对象的final类型域中。

将对象的引用保存到一个由锁保护的域中。

 

3)线程安全的容器:

MapHashtablesynchronizedMapconcurrentMap

CollectionVectorCopyOnWriteArrayListCopyOnWriteArraySetSynchronizedListSynchronizedSet

队列:BlockingQueueConcurrenLinkedQueue

 

对象的发布取决于它的可变性:

不可变对象可以通过任何机制来发布;

事实不可变对象必须通过安全的方式来发布;

可变对象必须通过安全的方式来发布,并且是线程安全的或者是由锁保护起来的。

 

 

使用共享对象的策略:

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

只读共享:没有额外同步的情况下,只读共享对象任何线程都可以读取,但不能修改。只读共享对象包括不可变对象和事实不可变对象。

线程安全共享:线程安全的对象在其内部实现同步。

保护对象:保护对象只能通过持有特定的锁来访问。

 

3、对象的组合

1)设计线程安全类

设计线程安全类的三大基本要素:

找出构成对象状态的所有变量;

找出约束状态变量的不变性条件;

建立对象状态的并发访问管理策略;

 

2)线程安全委托

如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态的转换,那么可以将线程安全委托给底层的状态变量。如用volatileatomic类、安全容器包装修饰。

 

在现有的安全类中加功能

扩展类:继承安全类 或者 内部包装一个安全类

内部包装一个安全类:

 

要确认同步的内容修改为同一个锁:

 

 

类中拥有指向底层List的唯一外部引用,就能确保线程安全:

 

4、基础构建模块

同步容器:Vectorhashtable

并发容器:ConcurrentHashMapCopyOnWriteArrayListconcurrentMapCopyOnWriteArraySetConcurrentLinkedQueue

 

阻塞队列:LinkedBlockingQueueArrayBlockingQueuePriorityBlockingQueue

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

线程中断

恢复中断:

 

 

传递InterruptedException :不捕获异常直接抛出,或者捕获异常简单处理后再抛出。

 

 

同步工具类

同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。

常见的同步工具类:阻塞队列、信号量(Semaphore)、栅栏(Barrier)、闭锁(Latch)。

 

CountDownLatchCyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同:

CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;

CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;

    另外,CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。

Semaphore其实和锁有点类似,它一般用于控制对某组资源的访问权限。

 

 

CountDownLatch一种灵活闭锁的实现,它可以使一个或多个线程等待一事件发生后再执行。

闭锁状态包括一个计数器:countDown方法递减计数器,await方法等待计数器到达零。

 

FutureTask也可以做闭锁,它表示的计算是通过Callable来实现的,相当于一种可生产结果的Runnable,可以处于:等待运行、正在运行、运行完成这三种状态。

FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程。

http://blog.csdn.net/liulipuo/article/details/39029643

 

 

编写多线程程序有三种方法,Thread,Runnable,Callable.

RunnableCallable的区别是,
(1)Callable规定的方法是call(),Runnable规定的方法是run().
(2)Callable的任务执行后可返回值,而Runnable的任务是不能返回值得
(3)call方法可以抛出异常,run方法不可以
(4)运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

Future就是对于具体的Runnable或者Callable任务的执行结果进行

取消、查询是否完成、获取结果、设置结果操作。get方法会阻塞,直到任务返回结果(Future简介)

FutureTask则是一个RunnableFuture<V>,而RunnableFuture实现了Runnbale又实现了Futrue<V>这两个接口另外它还可以包装RunnableCallable<V>, 由构造函数注入依赖。Runnable注入会被Executors.callable()函数转换为Callable类型,即FutureTask最终都是执行Callable类型的任务。

由于FutureTask实现了Runnable,因此它既可以通过Thread包装来直接执行,也可以提交给ExecuteService来执行。

并且还可以直接通过get()函数获取执行结果,该函数会阻塞,直到结果返回。因此FutureTask既是Future

Runnable,又是包装了Callable( 如果是Runnable最终也会被转换为Callable ), 它是这两者的合体。

 

 

 

 

计数信号量(Counting Semaphore):用来控制同时访问某个资源的操作数量,或同时执行某个指定操作的数量。

 

Semaphore可以控同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。

 

 

 

栅栏:它能阻塞一组线程只到某个事件发生。所有线程必须同时到达栅栏的位置,才能继续执行。

CyclicBarrier:可以使一定数量的参与方反复的在栅栏位置汇集。

 

 

 

 

构建高效且可伸缩的结果缓存:

 

第一种用synchronized进行同步,确保线程安全。但是性能差,长时间计算时间长。

 

 

 

第二种:用ConcurrentHashMap代替HashMap,多线程可以并发使用。

漏洞:当两个线程同时调用compute方法时可能会计算出相同的结果,浪费一次计算。

 

 

 

第三种并发容器中包含FutrueFutreTask表示一个计算过程,如果有结果get()方法立刻放回结果,否则一直阻塞到结果计算完成再返回。如果其他线程在计算结果,那么新到的线程就等待这个结果被计算出来。

漏洞:if操作非原子,依然有计算相同值的概率。

 

 

 

第四种:ConcurrentMap的原子方法putIfAbsent(),避免第三种方法的漏洞。

 

 

 

 

 

5、任务执行

各自独立的任务可以并行,要有清晰的任务边界和明确的任务执行策略。

Executor

执行策略:

在什么线程中执行任务

任务按照什么顺序执行(FIFO\LIFO\优先级)

有多少任务可以并发执行

在队列中有多少任务等待执行

过载时拒绝策略:怎么选择拒绝的任务,怎么通知程序有任务被拒绝。

执行任务前后的,应该做些什么。

 

ExecutorService,completionService

Exec.invokeAll(tasks)

ExecutorServiceinvokeAll方法有两种用法:

1.exec.invokeAll(tasks)

2.exec.invokeAll(tasks, timeout, unit)

其中tasks是任务集合,timeout是超时时间,unit是时间单位

当所有任务都完成时、调用线程被中断时或者超过时限时,限时版本的invokeAll都会返回结果。超过时限后,任何尚未完成的任务都会被取消。

作为invokeAll的返回值,每个任务要么正常地完成,要么被取消。

 

 

6、线程取消与关闭

1)任务取消

Java没有提供取消线程的机制,但是提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的当前工作。

中断机制可以取消一个线程。通常中断是实现取消最合理的方式。

调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。(发出中断请求后,线程会在下一个合适的时刻中断自己)

使用静态的interrupt会清除当前线程的中断状态,必须对它进行处理:抛出InterruptedException或者再次调用interrupt恢复中断,否则中断状态将会被屏蔽。

 

每个线程都有自己的中断策略,所以除非你知道中断对该线程的含义,否则就不应该中断这个线程。

 

只有实现了线程中断策略的代码才可以屏蔽中断请求,在常规的任务和库代码中都不应该屏蔽中断请求。

 

可以通过Future来实现取消(Future有个cancel方法)。

 

处理不可中断的阻塞:可以使用类似中断的手段停止线程。

同步的I/O------close socket

获取锁----------lockInterruptibly

 

2)停止基于线程的服务

关闭ExecutorService:

shutDown正常关闭,一直等到队列中的任务都执行完后才关闭。

shutDownNow强行关闭,首先关闭当前正在执行的任务,然后返回所有尚未执行的任务清单。

 

shutDownNow不会返回正在执行的任务(这些任务首先被取消)。应该在线程运行时进行判断,并提供返回的方法:

 

 

 

 

3)处理非正常的线程终止

线程会由于发生一个未捕获异常而终止,并发程序中这种线程终止不易发觉,会出现线程“遗漏”,会引起意外的后果。

导致线程提前死亡的最主要的原因是RuntimeException

Thread API 提供了UncaughtExceptionHandler,它能够检测出某个线程由于未捕获的异常而终结的情况:

 

在运行时间较长的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。

如果希望任务在发生异常而失败时获得通知,并执行一些特定于任务的恢复操作,可以将任务封装在能捕获异常的RunnableCallable中,或改写ThreadPoolExecutorafterExecute方法。

 

 

7、线程池的使用

线程饥饿死锁:在线程池中,一个任务以来其他任务的执行,就可能产生死锁。在单线程的Executor中,一个任务将另一个任务提交到同一个Executor中,并且等待这个任务的结果,就会发生死锁。

只有当任务是同类且相互独立时,线程池的性能才最佳。运行时间长任务和运行时间短任务一起会造成“阻塞”,依赖型的任务会产生死锁。

1)设置线程池大小

计算密集型任务:Ncpu  +1

IO密集型任务:2Ncpu  需要计算等待时间和计算时间。

Ncpu=Runtime.getRuntime().availableProcessors();

 

影响线程池大小的资源:CPU周期、内存、文件句柄、套接字句柄、数据库连接等。

计算每个任务对资源的需求量。然后该资源可用的总量除以每个任务的需求量,就是线程池大小的上限。

 

2)配置ThreadPoolExecutor

new  ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, milliseconds,runnableTaskQueue,threadFactory, handler);

 

Executor一些基本的实现:

1newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

构造一个缓冲功能的线程池,配置corePoolSize=0maximumPoolSize=Integer.MAX_VALUEkeepAliveTime=60s,以及一个无容量的阻塞队列 SynchronousQueue,因此任务提交之后,将会创建新的线程执行;线程空闲超过60s将会销毁 

 

 


2newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

构造一个固定线程数目的线程池,配置的corePoolSizemaximumPoolSize大小相同,同时使用了一个无界LinkedBlockingQueue存放阻塞任务,因此多余的任务将存在再阻塞队列,不会由RejectedExecutionHandler处理


3newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。

构造有定时功能的线程池,配置corePoolSize,无界延迟阻塞队列DelayedWorkQueue;有意思的是:maximumPoolSize=Integer.MAX_VALUE,由于DelayedWorkQueue是无界队列,所以这个值是没有意义的 


4newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

构造一个只支持一个线程的线程池,配置corePoolSize=maximumPoolSize=1,无界阻塞队列LinkedBlockingQueue;保证任务由一个线程串行执行 

自定义线程池内部包装一个ThreadPoolExecutor,自定义init(),destory(),threadFactory implements ThreadFactory rejectedExecutionHandler implements RejectedExecutionHandler

 

3)扩展ThreadPoolExecutor

ThreadPoolExecutor子类中重写的方法:

beforeExecute

afterExecute

Terminated

可以在beforeExecuteafterExecute中添加日志、计时、监视、统计信息收集等功能。

无论任务是从run中正常结束还是抛出异常,都会执行afterExecute,但是如果是error就不会。

如果beforeExecute抛出RuntimeException,则runafterExecute都不执行。

Terminated在线程池关闭时调用。可以用来释放资源、发送通知、记录日志、收集finalize统计信息等:

 

 

 

4)递归算法并行化

若循环中任务都是独立的,可以用Executor把串行循环变成并行循环:

 

每次迭代执行任务的工作量大于管理一个新任务的工作量大,那么适合并行化。

 

 

 

 

闭锁:只用运行setValue才能运行getValue

 

 

 

8、图形用户界面应用程序

为什么GUI是单线程的。

GUI对象通过线程封闭机制来保证一致性。所有组件和数据模型对象都封闭在事件线程中。

 

Swing的单线程规则:Swing中的组件以及模型只能在这个事件分发线程中进行创建、修改以及查询。

 

1)用线程接力处理长时间任务

 

 

 

9、避免活跃性危险

 

1)死锁

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

 

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(可能会产生死锁),或阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

解决方法是:使用开放调用,以同步代码块替代同步方法(丢失一些原子性)来使用开放调用。

 

2)死锁避免和诊断

支持定时锁:LOCK.tryLock(time,tiemunit);等方法使用。

按顺序获取锁。

 

 

诊断:通过Thread Dump来识别死锁。定期触发线程转储,来观察程序的加锁行为。

在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。

 

3)其他活跃性

饥饿:线程无法获取所需要的资源而不能继续进行,就会发生饥饿。

(如:线程的优先级使用不当,低优先级的线程容易饥饿)

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

Thread API 定义的优先级是10,当每个平台的操作系统的优先级不一定是10个。线程调度最终取决于操作系统。

 

 

丢失信号:

 

 

活锁:当多个相互协作的线程都对彼此进行响应从而修改各自的的状态,并使得任何一个线程都无法继续执行时,就会发生活锁。

解决方法:在程序的重试机制中引入随机性。通过等待随机长度的时间和回退可以有效避免活锁的发生。

 

 

10 性能与可伸缩性

1)性能的思考

资源:CPU时钟周期、内存、网络宽带、I/O宽带、数据库请求、磁盘空间等。

 

多线程的额外开销:

线程之间的协调(如加锁、触发信号、内存同步等);

增加上下文的切换;

线程的创建与销毁;

线程调度等。

 

通过并发获取更好的性能,需要:更有效的利用现有的资源,和在出现新处理资源时程序尽可能的利用这些新资源。

 

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

伸缩性调优的目的:设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作。

 

对性能进行调优时,一定要有明确的性能需求(这样才知道什么时候应该调优,什么时候应该停止)。还需要测试程序和真实的配置、负载环境等。

性能应以测试为基准,不能猜测。

 

2)Amdahl定律

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

 

F:必须被串行的部分;

N:处理器个数。

找出程序中串行部分,减小它。

 

3)线程的引入开销

上下文切换:

 

 

内存同步:

 

阻塞:

自螺旋等待:循环不断地尝试获取锁,直到获取锁;适合短时间能获取的锁。

挂起:挂起被阻塞的线程。适合需要长时间才能获取的锁。

 

4)减少锁竞争

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

影响锁竞争的因素:锁的请求频率和每次在持有该锁的时间。

 

减少锁竞争方法:

1)减少锁持有时间(缩小锁范围);

2)降低锁请求频率(减小锁的粒度:锁分解和锁分段技术);

3)使用带有协调机制的独占锁,这些机制允许更高的并发率。

 

4)检测CPU利用率:UNIX系统上的vmstatmpstatWINDOWS系统上的perfmon工具都可以检测。

 

通常CPU没有充分利用的原因:

负载不足------可能客户端系统负载能力。增加负载。

I/O密集型------检测网络通信流量,或使用工具检测;

外部限制------如数据库连接或web服务。分析依赖的外部服务;

锁竞争-----分析线程转储,检测锁竞争情况。

 

5)不使用对象池技术:通常,对象分配操作的开销比同步的开销更低。

 

 

11、并发程序的测试

1)正确的测试

基本的单元测试;

对阻塞操作的测试;

安全性测试;

资源管理的测试;

使用回调;

产生更多交替操作。

 

2)性能测试

吞吐量;

响应时间;

伸缩性。

 

 

3)避免性能测试的陷阱

   垃圾回收:运行时序无法预测,很可能影响最终测试的每次迭代时间。

动态编译:JVM会选择在应用程序线程或后台线程中执行编译过程,不同的选择会对计时结果产生影响。

对代码的路径不真实采样:编译器可能对已编译的代码优化,重排。不同程序中使用相同相同的方法时性能有差异。

无用代码的消除:

 

 

 

4)其他测试方法

代码审查;

静态分析工具:

 不同工具的分析对象及应用技术对比 

Java 静态分析工具

分析对象

应用技术

Checkstyle

Java 源文件

缺陷模式匹配

FindBugs

字节码

缺陷模式匹配;数据流分析

PMD

Java 源代码

缺陷模式匹配

Jtest

Java 源代码

缺陷模式匹配;数据流分析

 

内置编程规范

Checkstyle:

  • Javadoc 注释:检查类及方法的 Javadoc 注释
  • 命名约定:检查命名是否符合命名规范
  • 标题:检查文件是否以某些行开头
  • Import 语句:检查 Import 语句是否符合定义规范
  • 代码块大小,即检查类、方法等代码块的行数
  • 空白:检查空白符,如 tab,回车符等
  • 修饰符:修饰符号的检查,如修饰符的定义顺序
  • 块:检查是否有空块或无效块
  • 代码问题:检查重复代码,条件判断,魔数等问题
  • 类设计:检查类的定义是否符合规范,如构造函数的定义等问题

FindBugs:

  • Bad practice 坏的实践:常见代码错误,用于静态代码检查时进行缺陷模式匹配
  • Correctness 可能导致错误的代码,如空指针引用等
  • 国际化相关问题:如错误的字符串转换
  • 可能受到的恶意攻击,如访问权限修饰符的定义等
  • 多线程的正确性:如多线程编程时常见的同步,线程调度问题。
  • 运行时性能问题:如由变量定义,方法调用导致的代码低效问题。

PMD:

  • 可能的 Bugs:检查潜在代码错误,如空 try/catch/finally/switch 语句
  • 未使用代码(Dead code):检查未使用的变量,参数,方法
  • 复杂的表达式:检查不必要的 if 语句,可被 while 替代的 for 循环
  • 重复的代码:检查重复的代码
  • 循环体创建新对象:检查在循环体内实例化新对象
  • 资源关闭:检查 Connect,Result,Statement 等资源使用之后是否被关闭掉

Jtest

  • 可能的错误:如内存破坏、内存泄露、指针错误、库错误、逻辑错误和算法错误等
  • 未使用代码:检查未使用的变量,参数,方法
  • 初始化错误:内存分配错误、变量初始化错误、变量定义冲突
  • 命名约定:检查命名是否符合命名规范
  • Javadoc 注释:检查类及方法的 Javadoc 注释
  • 线程和同步:检验多线程编程时常见的同步,线程调度问题
  • 国际化问题:
  • 垃圾回收:检查变量及 JDBC 资源是否存在内存泄露隐患

 

面向方面的测试技术;

分析与监测工具。

 

 

12、显示锁

Java5之前对协调共享对象的访问机制只有synchronizedvolatile.

Java5增加了ReentrantLockReentrantLock不是替代内置锁的方法,而是当内置加锁机制不适用时,另一种可选择的高级功能。

1)Lock ReentrantLock

Lock:提供了一种无条件、可轮询的、定时的以及可中断的锁获取操作。

lock.lockInterruptibly()可以使得线程在等待锁是支持响应中断;

即:尝试获取锁。如果当前有别的线程获取了锁,则睡眠。当该函数返回时,有两种可能:a.已经获取了锁 
b.获取锁不成功,但是别的线程打断了它。则该线程会抛出IterruptedException异常而返回,同时该线程的中断标志会被清除。

 

Lock()lockInterruptibly()区别:

线程尝试获取锁操作失败后,在等待过程中,如果该线程被其他线程中断了,它是如何响应中断请求的。lock方法会忽略中断请求,继续获取锁直到成功;

lockInterruptibly则直接抛出中断异常来立即响应中断,由上层调用者处理中断。

 

如果要求被中断线程不能参与锁的竞争操作,则此时应该使用lockInterruptibly方法,一旦检测到中断请求,立即返回不再参与锁的竞争并且取消锁获取操作

 

lock.tryLock()可以使得线程在等待一段时间过后如果还未获得锁就停止等待而非一直等待。

 

内置锁的局限性:

无法中断一个正在等待获取锁的线程。

内置锁在进入同步块时,采取的是无限等待的策略,一旦开始等待,就既不能中断也不能取消,容易产生饥饿与死锁的问题

在线程调用notify方法时,会随机选择相应对象的等待队列的一个线程将其唤醒,而不是按照FIFO的方式,如果有强烈的公平性要求,比如FIFO就无法满足

 

lock.lockInterruptiblylock.tryLock()可以更好的制定获得锁的重试机制,而非盲目一直等待,可以更好的避免饥饿和死锁问题

 

ReentrantLock可以成为公平锁(非默认的),所谓公平锁就是锁的等待队列的FIFO(线程将按照它们发出请求的顺序来获取锁),不过公平锁会带来性能消耗,如果不是必须的不建议使用。

 

 

非块结构的锁:

内置锁的锁获得和释放都是基于代码块。

Lock的锁可以不局限于代码块中,如分段技术基于在散列的容器中实现了不同的散列链,以便使用不同的锁。可以用Lock给每个链表结点使用一个独立的锁,使不同的线程能独立的对链表的不同部分进行操作。

 

连锁式加锁

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

posted on 2017-09-17 12:53  在窗边的豆豆助  阅读(307)  评论(0编辑  收藏  举报