一.并发基础
1.什么是并行和并发?
并行表示两行同时(同时)做事。 并发意味着这样做一会儿,另一会儿做另一件事,有调度。
单核 CPU 并行(微观)是不可能存在的。
2.什么是活锁?
假设有两个线程1和2,它们都需要资源 A/B,假设1号线程占有了 A 资源,2号线号线程 B 资源;由于两个线程都需要同时拥有这两个资源才能工作,1号线程被释放,以避免 A 资源占有锁,2号线程释放 B 此时资源占有锁 AB 空闲时,两行同时抢锁,上述情况再次发生,此时活锁发生。简单类比,电梯遇到人,一个进一个出,对面占路,两个人同时向一个方向让路,来回重复,或者堵路。如果网上应用遇到活锁问题,恭喜你中奖。这种问题很难调查。
3.单线程创建方法
创建单线程的方法相对简单,通常只有两种方法:继承Thread类和实现Runnable接口;这两种方法更常用Demo然而,新手需要注意的问题是:
- 不管是继承Thread类还是实现Runable接口,业务逻辑写在run在方法中,线程启动时执行start()方法;
- 在不影响主线程代码执行顺序的情况下,打开新的线程不会阻止主线程执行;
- 不能保证新线程和主线程的代码执行顺序;
- 对于多线程序,在微观上,只有一个线程在某个时刻工作。多线程的目的是让它工作CPU忙起来;
- 通过查看Thread可以看到源码,Thread类是实现了Runnable接口,所以这两个本质上是一个;
4.终止线程运行的情况
线程调度器选择优先级最高的线程运行。但如发生以下情况,线程运行将终止:
- 调用线程体yield()方法,让出对CPU的占用权;
- 调用线程体sleep()使线程进入睡眠状态的方法;
- 线程由于I/O操作堵塞;
- 出现了另一个更高优先级的线程;
- 该线程的时间片在支持时间片的系统中耗尽。
5.如何减少上下文切换?
无锁并发编程是减少上下文切换的方法,CAS算法,使用最小线程和使用协程。
- 无锁并发编程。当多线程竞争锁时,它会导致上下文切换,因此在处理多线程数据时,可以避免使用锁,如数据ID按照Hash算法取模分段,不同线程处理不同段数据。
- CAS算法。Java的Atomic包使用CAS在不加锁的情况下,算法更新数据。
- 使用最小线程。避免创建不必要的线程,比如很少的任务,但是创建了很多线程来处理,这会导致大量的线程处于等待状态。
- 协程:在单线程中实现多任务的调度,并在单线程中保持多任务之间的切换。
6.如何检测上下文切换?
监测:
使用Lmbench3可以测量上下文切换的时长;
使用vmstat上下文切换的次数可以测量
排查步骤:
1.查询Java程序的进程ID,使用 jstack 命令 dump出线程文件
sudo -u admin/opt/ifeve/java/bin/jstack31177>/home/t engfei.fangtf/dump17
2.统计线程状态
[tengfei.fangtf@ifeve~]$grep java.lang.ThreadState dump17|awk '{
print $2$3$4$5} | sort | uniq -c 39 RUNNABLE 21 TIMED_WAITING(onobjectmonitor) 6 TIMED_WAITING(parking) 51 TIMED_WAITING(sleeping) 305 WAITING(onobiectmonitor) 3 WAITING(parking)
3.查看WAITING状态线程在做什么?
"http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in Objectwait()[0x0000000052423000] java.lang.Thread.State: WAITING(on object monitor) at java.lang.Objectwait(Native Method) -waiting on <0x00000007969b2280>(a orapachetomcat.utinetAprEndpoint$Worker at java.lang.Objectwait(Objectjava:485) at org.apache.tomcat.util.netAprEndpoint$Workerawait(AprEndpointjava:1464 -locked <0x00000007969b2280>(a org.apache.tomcatutilnetAprEndpoint$Worker) at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpointjava:1489) at java.lang.Thread.run(Threadjava:662)
4.减少JBOSS工作线程数
<maxThreads="250" maxHttpHeaderSize="8192" emptySessionPath="false"minSpareThreads="40" max SpareThreads="75" maxPostSize="512000"protocol="HTTP/1.1" enableLookups="false"redirectPort="8443" acceptCour nt="200" bufferSize="16384"
connectionTimeout="15000"disableUploadTimeout="fal se" useBodyEncodingForURI= "true">
7.什么是死锁?
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去
如上图例子:线程A 己经持有了资源2,它同时还想申请资源1,线程B 已经持有了资源1,它同时还想申请资源2,所以线程1 和线程2 就因为相互等待对方已经持有的资源,而进入了死锁状态。
8.写出死锁代码?
死锁的产生必须具备以下四个条件。
- 互斥条件:一个资源同时只能有一个线程占有.其他线程只能等待.
- 请求并持有条件:当前线程已经获取到一个资源,又获取其他资源,其他资源被别的线程占有,当前线程等待,但是不释放持有资源.
- 不可剥夺条件:占有资源期间,不能被其他线程剥夺,只能自己释放.
- 环路等待条件:等待资源形成环形链.a被A占有,b被B占有,A想获取b,B想获取a
public class DeadLockDemo {
private static String A = "A";
private static String B = "B";
public static void main(String[] args){
new DeadLockDemo().deadLock();
}
private void deadLock(){
Thread t1 = new Thread(new Runnable(){
@Override
public void run(){
//线程1获取A的锁
synchronized (A){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e){
e.printStackTrace();
}
synchronized (B){
System.out.println("1");
}
}
}
});
Thread t2 = new Thread(new Runnable(){
@Override
public void run(){
//线程2获取B的锁
synchronized (B){
//A对象已经被线程1持有
synchronized (A){
System.out.println("2");
}
}
}
});
t1.start();
t2.start();
}
}
9.如何避免死锁呢?
要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,目前只有请求并持有条件和环路等待条件是可以被破坏的。
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用locktryLock(timeou t)来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
如上题代码中,在线程B中获取资源的顺序和在线程A 中获取资源的顺序保持一致,其实资源分配有序性就是指,假如线程A 和线程B 都需要资源1,2,3,…, n 时,对资源进行排序,线程A 和线程B 只有在获取了资源n-1 时才能去获取资源n.
public class DeadLockRelessDemo {
private static String A = "A";
private static String B = "B";
public static void main(String[] args){
new DeadLockRelessDemo().deadLock();
}
private void deadLock(){
Thread t1 = new Thread(new Runnable(){
@Override
public void run(){
//线程1获取A的锁
synchronized (A){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e){
e.printStackTrace();
}
synchronized (B){
System.out.println("1");
}
}
}
});
Thread t2 = new Thread(new Runnable(){
@Override
public void run(){
//线程2也获取A的锁
synchronized (A){
synchronized (B){
System.out.println("2");
}
}
}
});
t1.start();
t2.start();
}
}
10.线程,进程,协程的区别?
进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位.
线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。操作系统在分配资源时是把资源分配给进程的,但是CPU 资源比较特殊,它是被分配到线程的,因为真正要占用CPU 运行的是线程,所以也说线程是CPU 分配的基本单位。在Java 中,当我们启动main 函数时其实就启动了一个JVM进程,而main 函数所在的线程就是这个进程中的一个线程,也称主线程
一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈区域。
进程:静态分配内存资源的最小单位
线程:动态执行任务的最小单位
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。协程也叫纤程.
11.线程的状态?
状态名称 | 说明 |
---|---|
NEW | 初始状态,线程被构建,但是还没有调用start方法 |
RUNNABLE | 运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称作“运行中” |
BLOCKED | 阻塞状态,表示线程阻塞于锁 |
WAITING | 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断) |
TIME WAITING | 超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的 |
TERMINATED | 终止状态,表示当前线程已经执行完毕 |
- NEW :创建了线程对象但尚未调用start()方法时的状态。
- RUNNABLE:线程对象调用start()方法后,线程处于可运行状态,此时线程等待获取CPU执行权。
- BLOCKED:线程等待获取锁时的状态。
- WAITING:线程处于等待状态,处于该状态标识当前线程需要等待其他线程做出一些特定的操作唤醒自己。
- TIME_WAITING:超时等待状态,与WAITING不同,在等待指定的时间后会自行返回。
- TERMINATED:终止状态,表示当前线程已执行完毕。
12.线程状态变迁?
- RUNNABLE有2种状态,一种是running,调用yield变为ready,一种是ready,调用run为running
- New状态为初始状态,New Thread的时候是new这种状态,调用start方法后,变为runnable
- Terminated任务执行完成后线程的状态为终止
- runnable状态到waiting状态
- wait()
- join() 不需要主动唤醒
- park()
- waiting状态到runnable状态
- 唤醒
- notify()
- notifyAll()
- unpark(thread)
- runnable状态到timed_waiting状态
- sleep(long)
- wait(long)
- join(long) 不需要主动唤醒
- parkNanos()
- parkUntil()
- timed_waiting状态到runnable状态
- notify()
- notifyAll()
- unpark(thread)
- blocked到runnable
- 获取到锁
- runnable到blocked
- synchronized方法
- synchronized块
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NWZLpdce-1655275972188)(https://s2.loli.net/2022/06/09/Z9SnLoIrc2TkwuJ.png)]
13.CPU术语定义?
术语 | 英文单词 | 描述 |
---|---|---|
内存屏障 | memory barriers | 是一组处理器指令,用于实现对内存操作的顺序限制 |
缓冲行 | cache line | 缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期 |
原子操作 | atomic operations | 不可中断的一个或一系列操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1L2.L3的或所有) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写人到不存在的内存区域 |
14.java内存模型?
Java线程之间的通信由Java内存模型(本文简称为JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
JMM关于同步的规定:
- 线程解锁前,必须把共亨变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P3gfqmdT-1655275972189)(https://s2.loli.net/2022/04/15/etHIbFohEOiPJS7.png)]
15.什么是Daemon线程?
Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置为Daemon线程。注意Daemon属性需要在启动线程之前设置,不能在启动线程之后设置。Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时 Daemon线程中的fnally块并不一定会执行.
public class Daemon{
public static void main(String[] args){
Thread thread=new Thread(new DaemonRunner(),"DaemonRunner");
thread.setDaemon(true);
thread.start();
}
static class DaemonRunner implements Runnable{
@Override
public void run() {
try{
SleepUtilssecond(10);
} finally{
System.out.println("DaemonThread finally run.");
}
}
}
}
16.说说三个中断方法?
说说interrupt(),interrupted(),isInterrupted()的区别
- void interrupt()方法:中断线程,例如,当线程A运行时,线程B可以调用钱程A的interrupt()方法来设置线程A的中断标志为true并立即返回.仅仅是设置标志,线程A实际并没有被中断,它会继续往下执行.如果线程A因为调用了wait系列函数、join方法或者sleep方法而被阻塞挂起,这时候若线程B调用线程A的interrupt()方法,线程A会在调用这些方法的地方抛出InterruptedException异常而返回。
- boolean isinterrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false.
- boolean interrupted()方法:检测当前线程是否被中断,如果是返回true,否则返回false.与isInterrupted不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是static方法,可以通过Thread类直接调用.另外从下面的代码可以知道,在interrupted()内部是获取当前调用线程的中断标志而不是调用interrupt方法的实例对象的中断标志。
- Java中断机制是一种协作机制,中断只是给线程打一个中断标记,具体如何操作还要看线程自己interrupt()函数作用仅仅是为线程打一个中断标记。interrupted()与isInterrupted()函数,都是返回线程的中断状态,但是interrupted()被static修饰,返回当前线程的中断状态,并且会清除线程的中断标记;而isInterrupted()未static修饰,被Thread对象调用,它不会清除线程的中断标记。
从代码中可以看出,interrupted方法调用的currentThread()的native方法,而isInterrupted方法调用的实例对象的native方法.Native方法传参:true代表清除中断标志,false代表不清除中断标志
public void interrupt(){
if (this != Thread.currentThread())
checkAccess();
synchronized (blockerLock){
Interruptible b = blocker;
if (b != null){
interrupt0(); //Just to set the interrupt flag
b.interrupt(this);
return;
}
}
interrupted();
}
public static boolean interrupted(){
return currentThread().isInterrupted(true);
}
public boolean isInterrupted(){
return isInterrupted(false);
}
private native boolean isInterrupted(boolean ClearInterrupted);
17.说说join方法和yeild方法?
在项目实践中经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理.Thread类中有一个join方法就可以做这个事情,join方法是Thread类直接提供的.join是无参且返回值为void的方法。
Thread类中有一个静态的yield方法,当一个线程调用yield方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU使用.我们知道操作系统是为每个线程分配一个时间片来占有CPU的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,而当一个线程调用了Thread类的静态方法yield时,是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,这暗示线程调度器现在就可以进行下一轮的线程调度。
当一个线程调用yield方法时,当前线程会让出CPU使用权,,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出CPU的那个线程来获取CPU执行权。
join方法可中断响应,释放CPU,释放的时当前调用join方法对象的锁.join方法会被隐式唤醒
18.说说sleep和yeild和wait区别?
sleep方法与yeild方法的区别在于,当线程调用sleep方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。不会释放资源锁,只是让出CPU的时间片.线程会阻塞.
而调用yield方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。
wait方法会阻塞,会释放锁,会让出CPU的使用权,且不会参与锁竞争,除非被唤醒后才参与竞争.
sleep方法不释放锁,释放cpu,可响应中断.先清除中断标志,再打印异常
19.说说notify和wait方法?
一个线程调用共享对象的notify()方法后,会唤醒一个在该共享变量上调用wait()方法后被挂起的线程。此外,被唤醒的线程不能马上从wait方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回(调用wait方法后,会释放当前共享对象的锁,如果不释放会造成死锁),也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会立即获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。
类似wait系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的notify()方法,否则会抛出IllegaMonitorStateException异常。
Thread类的方法:sleep(),yield() Object的方法:wait()和notify()、notifyAll()
20.说说对象的监视器锁?
一个线程如何才能获取到对象的监视器锁呢
- 执行synchronized 同步代码块时,使用该共享变量作为参数。
synchronized (MonitorTest.class){
//do something
}
- 调用该共享变量的方法,并且该方法使用了synchronized 修饰。
synchronized void add(int a){
//do something
}
9.线程阻塞和唤醒方法对比?
wait()、notify()、notifyAll() 这三个方法是Object超类中的方法.
await()、signal()、signalAll() 这三个方法是Lock的Condition中的方法.
21.程序计数器为何线程私有?
为何要将程序计数器设置为线程私有的
程序计数器是一块内存区域,用来记录线程当前要执行的指令地址.线程是占用CPU执行的基本单位,而CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU时间片用完后,要让出CPU,等下次轮到自己的时候再执行.那么如何知道之前程序执行到哪里了呢?其实程序计数器就是为了记录该线程让出CPU时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行.另外需要注意的是,如果执行的是native方法,那么pc计数器记录的是undefined地址,只有执行的是Java代码时pc计数器记录的才是下一条指令的地址。
22.jvm内存划分?
局部变量,对象实例,jvm加载的类,常量及静态变量都存储在内存的什么部位?是线程私有的吗
- 每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。
- 堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用new操作创建的对象实例。
- 方法区则用来存放虚拟机加载的类、常量及静态变量等信息,也是线程共享的。
23.继承Thread类的优劣?
使用继承方式的好处是,在run()方法内获取当前线程直接使用this 就可以了,无须使用Thread.currentThread()方法;不好的地方是Java 不支持多继承,如果继承了Thread 类,那么就不能再继承其他类。另外任务与代码没有分离,当多个线程执行一样的任务时需要多份任务代码, Runable 则没有这个限制。
public class DemoTest extends Thread {
// private int tickets = 20;
private volatile int tickets = 20;
@Override
public void run(){
synchronized (this){
while (tickets > 0){
System.out.println(Thread.currentThread().getName()+"卖出一张票"+ tickets);
tickets--;
}
}
}
public static void main(String[] args){
//实际上一共卖出了80张票,每个线程都有自己的私有的非共享数据。都认为自己有20张票
DemoTest test4 = new DemoTest();
DemoTest test5 = new DemoTest();
DemoTest test6 = new DemoTest();
DemoTest test7 = new DemoTest();
test4.setName("一号窗口:");
test5.setName("二号窗口:");
test6.setName("三号窗口:");
test7.setName("四号窗口:");
test4.start();
test5.start();
test6.start();
test7.start();
}
}
24.直接调用wait方法?
IllegalMonitorStateException出现的原因
如果调用wait()方法的线程没有先获取该对象的监视器锁,则调用wait方法时调用线程会抛出IllegalMonitorState Exception 异常。Object.notify(), Object.notifyAll(), Object.wait(), Object.wait(long), Object.wait(long, int)都会存在这个问题
public class ExceptionTest {
public static void main(String[] args){
Object object = new Object();
try {
object.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
25.什么是虚假唤醒?
什么是虚假唤醒?如何避免虚假唤醒?
在一个线程没有被其他线程调用notify()、 notifyAll()方法进行通知,或者被中断,或者等待超时,这个线程仍然可以从挂起状态变为可以运行状态(也就是被唤醒),这就是所谓的虚假唤醒。
虽然虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个while循环中调用wait()方法进行防范.退出循环的条件是满足了唤醒该线程的条件。
synchronized (obj){
//do something
while (条件不满足){
obj.wait();
}
}
While在这里是防止虚假唤醒的关键,试想下,一旦发生虚假唤醒,线程会根据while添加再次进行判断,一旦条件不满足,会立即再次将线程挂起.
26.说说ThreadLocal?
ThreadLocal是JDK包提供的,它提供了线程本地变量,也就是如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本.当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。
创建一个ThreadLocal变量后,每个线程都会复制一个变量到自己的本地内存,如下图所示。
27.ThreadLocal的原理?
Thread类中有一个threadLocals和一个inheritableThreadLocals,它们都是ThreadLocalMap类型的变量,而ThreadLocalMap是一个定制化的Hashmap.在默认情况下,每个线程中的这两个变量都为null,只有当前线程第一次调用ThreadLocal的set或者get方法时才会创建它们.其实每个线程的本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的threadLocals变量里面.也就是说,ThreadLocal类型的本地变量存放在具体的线程内存空间中。ThreadLocal就是一个工具壳,它通过set方法把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里面将其拿出来使用.如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的threadLocals变量里面,所以当不需要使用本地变量时可以通过调用ThreadLocal变量的remove方法,从当前线程的threadLocals里面删除该本地变量。另外,Thread里面的threadLocals为何被设计为map结构?很明显是因为每个线程可以关联多个ThreadLocal变量。
在Thread类中有以下变量
/* ThreadLocal values pertaining to this thread. This map is maintained * by the ThreadLocal class.*/
ThreadLocal.ThreadLocalMap threadLocals = null;
/* * InheritableThreadLocal values pertaining to this thread. This map is * maintained by the InheritableThreadLocal class. * Inheritable 可继承的 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
- ThreadLocal中在set操作时,key为当前ThreadLocal对象。
- ThreadLocal会为每个线程都创建一个ThreadLocalMap,对应程序中的t.threadLocals = new ThreadLocalMap(this, firstValue),ThreadLocalMap为当前线程的属性。
- 通过对每个线程创建一个ThreadLocalMap实现本地副本。当取值时,实际上就是通过key在map中取值,当然此时的key为ThreadLocal对象,而map为每个线程独有的map,从而实现变量的互不干扰。
28.ThreadLocal中set方法?
public class ThreadLocalTest { public static void main(String[] args){ ThreadLocal<String> t1 = new ThreadLocal<>(); t1.set("1"); t1. 标签:
aqs继电器14r继电器输出双重pid3os3do安全继电器