进阶之路 | 奇妙的Thread之旅

栏目: IT技术 · 发布时间: 4年前

内容简介:本文已经收录到我的Github个人博客,欢迎大佬们光临寒舍:

前言

本文已经收录到我的Github个人博客,欢迎大佬们光临寒舍:

我的GIthub博客

需要已经具备的知识:

Thread
AsyncTask

学习导图:

进阶之路 | 奇妙的Thread之旅

一.为什么要学习 Thread ?

Android 中,几乎完全采用了 Java 中的线程机制。线程是最小的调度单位,在很多情况下为了使 APP 更加流程地运行,我们不可能将很多事情都放在主线程上执行,这样会造成严重卡顿( ANR ),那么这些事情应该交给子线程去做,但对于一个系统而言,创建、销毁、调度线程的过程是需要开销的,所以我们并不能无限量地开启线程,那么对线程的了解就变得尤为重要了。

本篇文章将带领大家由浅入深,从 线程的基础 ,谈到 同步机制 ,再讲到 阻塞队列 ,接着提及 Android 中的线程形态 ,最终一览 线程池机制

话不多说,赶紧开始奇妙的 Thread 之旅吧!

二.核心知识点归纳

2.1 线程概述

Q1:含义

线程是 CPU 调度的最小单位

注意与进程相区分

Q2:特点

线程是一种 受限 的系统资源。即线程不可无限制的产生且线程的创建和销毁都有一定的开销

Q:如何避免频繁创建和销毁线程所带来的系统开销?

A:采用 线程池 ,池中会缓存一定数量的线程,进而达到效果

Q3:分类

  • 按用途分为两类:
  • 主线程:一般一个进程只有一个主线程,主要处理 界面交互 相关的逻辑

  • 子线程:除主线程之外都是子线程,主要用于执行 耗时操作

  • 按形态可分为三类:
  • AsyncTask :底层封装了线程池和 Handler ,便于执行后台任务以及在主线程中进行 UI 操作
  • HandlerThread :一种具有 消息循环 的线程,其内部可使用 Handler
  • IntentService :一种 异步、会自动停止 的服务,内部采用 HandlerThreadHandler

进阶之路 | 奇妙的Thread之旅

PS:想详细了解 Handler 机制的读者,推荐一篇笔者的文章: 进阶之路 | 奇妙的Handler之旅

Q4:如何安全地终止线程?

对于有多线程开发经验的开发者,应该大多数在开发过程中都遇到过这样的需求,就是在某种情况下,希望立即停止一个线程

比如:做 Android 开发,当打开一个界面时,需要开启线程请求网络获取界面的数据,但有时候由于网络特别慢,用户没有耐心等待数据获取完成就将界面关闭,此时就应该立即停止线程任务,不然一般会内存泄露,造成系统资源浪费,如果用户不断地打开又关闭界面,内存泄露会累积,最终导致内存溢出, APP 闪退

所以,笔者希望能和大家探究下:如何安全地终止线程?

A1:为啥不使用stop?

Java官方早已将它废弃,不推荐使用

  • stop 是通过立即抛出 ThreadDeath 异常,来达到停止线程的目的,此异常抛出有可能发生在任何一时间点,包括在 catchfinally 等语句块中,但是此异常并不会引起程序退出
  • 异常抛出,导致线程会 释放 全部所持有的 ,极可能引起 线程安全 问题

A2:提供单独的取消方法来终止线程

示例 DEMO

public class MoonRunner implements Runnable {
        private long i;
        //注意的是这里的变量是用volatile修饰
        volatile boolean on = true;

        @Override
        public void run() {
            while (on) {
                i++;
            }
            System.out.println("sTop");
        }
        
        //设置一个取消的方法
        void cancel() {
            on = false;
        }
    }

注意:这里的变量是用 volatile 修饰,以保证 可见性 ,关于 volatile 的知识,笔者将在下文为您详细解析

A3:采用interrupt来终止线程

Thread 类定义了如下关于中断的方法:

进阶之路 | 奇妙的Thread之旅

原理:

  • 调用 Thread 对象的 interrupt 函数并不是立即中断线程,只是将线程 中断 状态 标志 设置为 true

  • 当线程运行中有调用其阻塞的函数时,阻塞函数调用之后,会不断地轮询检测中断状态标志是否为 true ,如果为 true ,则停止阻塞并抛出 InterruptedException 异常,同时还会重置中断状态标志,因此需要在 catch 代码块中需调用 interrupt 函数,使线程再次处于中断状态
  • 如果中断状态标志为 false ,则继续阻塞,直到阻塞正常结束

具体的 interrupt 的使用方式可以参考这篇文章: Java线程中断的正确姿势

2.2 同步机制

2.2.1 volatile

synchronized
volatile

Q1:先从 Java 内存模型聊起

  • Java 内存模型定义了 本地内存和主存 之间的抽象关系
  • 线程之间的 共享变量 存储在 主存
  • 每个线程都有一个 私有的本地内存 (工作内存),本地内存中存储了该线程共享变量的 副本

进阶之路 | 奇妙的Thread之旅

  • 线程之间通信的步骤
  • 线程 A 将其 本地内存更新过的共享变量刷新到主存 中去
  • 线程 B主存 中去 读取 线程A之前已 更新过的共享变量

Q2:原子性、可见性和有序性了解多少

a1:原子性Atomicity:

  • 定义:原子性操作就是指这些操作是不可中断的,要做一定做完,要么就没有执行
  • 对基本数据类型变量的 读取和赋值 操作是原子性操作

注意:这里的 赋值 操作是指 将数字赋值给某个变量

下面由 DEMO 解释更加通俗易懂

x=3;  //原子性操作
y=x;  //非原子性操作  原因:包括2个操作:先读取x的值,再将x的值写入工作内存
x++;  //非原子性操作  原因:包括3个操作:读取x的值、对x的值进行加1、向工作内存写入新值
  • volatile 不支持原子性(想探究原因的,笔者推荐一篇文章: 面试官最爱的volatile关键字
  • 保证整块代码原子性(例如 i++ )的方法:借助于 synchronizedLock ,以及并发包下的 atomic 的原子操作类

a2:可见性Visibility

  • 定义:一个线程修改的结果,另一个线程马上就能看到

  • Java 就是利用 volatile 来提供可见性的

原因:当一个变量被 volatile 修饰时,那么对它的修改会 立刻刷新到主存 ,同时使 其它线程的工作内存 中对此变量的 缓存行失效 ,因此需要读取该变量时,会去内存中读取新值

  • 其实通过 synchronizedLock 也能够保证可见性,但是 synchronizedLock 的开销都更大

a3:有序性Ordering

  • 指令重排序 的定义:大多数现代微处理器都会采用将指令乱序执行的方法, 在条件允许的情况下, 直接运行当前有能力立即执行的后续指令, 避开获取下一条指令所需数据时造成的等待
  • 什么时候不进行 指令重排序
  • 符合数据依赖性:
//x对a有依赖
a = 1;
x = a;
as-if-serial
happens-before
happens-before
happens-before

这就是 happens-before 传递性

  • volatile 通过 禁止指令重排序 的方式来保证有序性

Q3:应用场景有哪些?

  • 状态量标记

线程的终止的时候的状态控制,示例DEMO如前文

  • DCL

避免指令重排序:

假定创建一个对象需要:

instance

上面的2和3操作是有可能重 排序 的, 如果3重排序到2的前面, 这时候2操作还没有执行, instance!=null , 当然不是安全的

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

Q4:原理:

  • 如果把加入 volatile 关键字的代码和未加入 volatile 关键字的代码都生成汇编代码,会发现加入 volatile 关键字的代码会多出一个 lock 前缀指令
  • lock 前缀指令实际相当于一个 内存屏障 ,内存屏障提供了以下功能:
  • 重排序时不能把后面的指令重排序到内存屏障之前的位置
  • 使得本 CPUCache 写入内存
  • 写入动作也会引起别的 CPU 或者别的内核无效化其 Cache ,相当于让新写入的值对别的线程可见

2.2.2 重入锁与条件对象

synchronized 关键字自动为我们提供了锁以及相关的条件,大多数需要显式锁的时候,使用 synchronized 非常方便,但是当我们了解了重入锁和条件对象时,能更好地理解 synchronized 和阻塞队列

Q1:重入锁的定义

  • 可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁
  • ReentrantLocksynchronized 都是可重入锁

重复调用锁的 DEMO 如下:

public class ReentrantTest implements Runnable {

    public synchronized void get() {
        System.out.println(Thread.currentThread().getName());
        set();
    }

    public synchronized void set() {
        System.out.println(Thread.currentThread().getName());
    }

    public void run() {
        get();
    }

    public static void main(String[] args) {
        ReentrantTest rt = new ReentrantTest();
        for(;;){
            new Thread(rt).start();
        }
    }
}

Q2:什么是条件对象 Condition

  • 条件对象来管理那些已经 获得了一个锁 但是却 不能做有用工作 的线程,条件对象又被称作条件变量
  • 一般要配合 ReentrantLock 使用,用 Condition.await() 可以 阻塞当前线程 ,并 放弃锁

Q3:下面说明重入锁与条件对象如何协同使用

  • 支付宝转账 的例子(支付宝打钱,狗头.jpg)
  • 场景是这样的:
//转账的方法
public void transfer(int from, int to, int amount){
    //alipay是ReentrantLock的实例
    alipay.lock();
    try{
        //当要转给别人的钱大于你所拥有的钱的时候,调用Condition的await可以阻塞当前线程,并放弃锁
        while(accounts[from] < amount){
            condition.await();
        }
                 
        ...//一系列转账的操作
            //阻塞状态解除,进入可运行状态
        condition.signalAll();
    }
    finally{
        alipay.unlock();
    }
}

想要更深一步了解重入锁的读者,可以看下这篇文章: 究竟什么是可重入锁?

2.2.3 synchronized

Q1: synchronized 有哪几种实现方式?

  • 同步代码块
  • 同步方法

Q2: synchronizedReentrantLock 的关系

  • 两者都是重入锁
  • 两者有些方法互相对应
  • wait 等价于 condition.await()
  • notifyAll 等价于 condition.signalAll()

Q3:使用场景对比

类型 使用场景
阻塞队列 一般实现同步的时候使用
同步方法 如果同步方法适合你的程序
同步代码块 不建议使用
Lock/Condition 需要使用 Lock/Condition 的独有特性时

2.3 阻塞队列

为了更好地理解线程池的知识,我们需要了解下阻塞队列

Q1:定义

  • 阻塞队列 BlockingQueue 是一个支持两个附加操作的队列。这两个附加的操作是:
  • 在队列为空时,获取元素的线程会等待队列变为非空
  • 当队列满时,存储元素的线程会等待队列可用

Q2:使用场景:

阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

Q3:核心方法

方法\处理方式 抛出异常 返回特殊值 一直阻塞 超时退出
插入方法 add(e) offer(e) put(e) offer(e,time,unit)
移除方法 remove() poll() take() poll(time,unit)
检查方法 element() peek() 不可用 不可用

Q4:JAVA中的阻塞队列

名称 含义
ArrayBlockingQueue 数组 结构组成的 有界 阻塞队列(最常用)
LinkedBlockingQueue 链表 结构组成的 有界 阻塞队列(最常用)注意:一定要指定大小
PriorityBlockingQueue 支持 优先级排序无界 阻塞队列。默认自然升序 排列
DelayQueue 支持 延时 获取元素的无界阻塞队列。
SynchronousQueue 不存储 元素的阻塞队列(可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程)
LinkedTransferQueue 链表 结构组成的 无界 阻塞队列
LinkedBlockingDeque 链表 结构组成的 双向阻塞 队列(双向队列指的是可以从队列的两端插入和移出元素)

进阶之路 | 奇妙的Thread之旅

Q5:实现原理:

  • 底层利用了 ReentrantLock & Condition 来实现自动加锁和解锁的功能
  • 如果想详细了解阻塞队列实现原理的源码,笔者推荐一篇文章: Android并发学习之阻塞队列

2.4 Android 中的线程形态

2.4.1 AsyncTask

Q1: 定义 :一种轻量级的 异步 任务类

Android 中实现异步任务机制有两种方式: HandlerAsyncTask

  • Handler 机制存在的 问题 :代码相对臃肿;多任务同时执行时不易精确控制线程。
  • 引入 AsyncTask好处 :创建异步任务更简单,直接继承它可方便实现后台异步任务的执行和进度的回调更新UI,而无需编写任务线程和 Handler 实例就能完成相同的任务。

Q2:五个核心方法:

方法 运行线程 调用时刻 作用
onPreExecute() 主线程 在异步任务执行之前被调用 可用于进行一些界面上的 初始化 操作
doInBackground() 子线程 异步任务执行时 可用于处理所有的 耗时任务 。若需要更新 UI 需调用 publishProgress()
onProgressUpdate() 主线程 调用 publishProgress() 之后 可利用方法中携带的参数如 Progress 来对 UI 进行相应地更新
onPostExecute() 主线程 在异步任务执行完毕并通过 return 语句返回时被调用 可利用方法中返回的数据来进行一些 UI 操作
onCancelled() 主线程 当异步任务被取消时被调用 可用于做 界面取消 的更新

注意:

  • 不要直接调用上述方法
  • AsyncTask 对象必须在 主线程 创建

Q3:开始和结束异步任务的方法

  • execute()
  • 必须在 主线程 中调用
  • 作用:表示开始一个异步任务
  • 注意:一个异步对象只能调用一次 execute() 方法
  • cancel()
  • 必须在 主线程 中调用
  • 作用:表示停止一个异步任务

Q4:工作原理:

  • 内部有一个静态的 Handler 对象即 InternalHandler
  • 作用:将执行环境从线程池切换到主线程;通过它来发送任务执行的进度以及执行结束等消息

  • 注意:必须在主线程中创建

  • 内部有两个线程池:
  • SerialExecutor :用于任务的排队,默认是 串行 的线程池
  • THREAD_POOL_EXECUTOR :用于真正执行任务
  • 排队执行过程:
  • 把参数 Params 封装为 FutureTask 对象,相当于 Runnable
  • 调用 SerialExecutor.execute()FutureTask 插入到任务队列 tasks
  • 若没有正在活动的 AsyncTask 任务,则就会执行下一个 AsyncTask 任务。执行完毕后会继续执行其他任务直到所有任务都完成。即默认使用 串行 方式执行任务。

执行流程图:

进阶之路 | 奇妙的Thread之旅

注意: AsyncTask 不适用于进行特别耗时的后台任务,而是建议用线程池

如果想要了解具体源码的读者,笔者推荐一篇文章: Android AsyncTask完全解析,带你从源码的角度彻底理解

2.4.2 HandlerThread

Q1:定义:

HandlerThread 是一个线程类,它继承自 Thread

与普通 Thread 的区别:具有 消息循环 的效果。原理:

  • 内部 HandlerThread.run() 方法中有 Looper ,通过 Looper.prepare() 来创建消息队列,并通过 Looper.loop() 来开启消息循环

Q2:实现方法

  • 实例化一个 HandlerThread 对象,参数是该线程的名称
  • 通过 HandlerThread.start() 开启线程
  • 实例化一个 Handler 并传入 HandlerThread 中的 Looper 对象,使得与 HandlerThread 绑定
  • 利用 Handler 即可执行异步任务
  • 当不需要 HandlerThread 时,通过 HandlerThread.quit() / quitSafely() 方法来终止线程的执行
private HandlerThread myHandlerThread ;  
private Handler handler ;  
@Override  
protected void onCreate(Bundle savedInstanceState) {  
    super.onCreate(savedInstanceState);  
   setContentView(R.layout.activity_main);  
   //实例化HandlerThread
   myHandlerThread = new HandlerThread("myHandler") ;  
   //开启HandlerThread
   myHandlerThread.start();  
   //将Handler对象与HandlerThread线程绑定
   handler =new Handler(myHandlerThread.getLooper()){  
       @Override  
        publicvoid handleMessage(Message msg) {  
           super.handleMessage(msg);  
            // 这里接收Handler发来的消息,运行在handler_thread线程中  
            //TODO...  
        }  
    };  
   
   //在主线程给Handler发送消息  
   handler.sendEmptyMessage(1) ;  
   new Thread(new Runnable() {  
       @Override  
        publicvoid run() {  
           //在子线程给Handler发送数据  
           handler.sendEmptyMessage(2) ;  
        }  
    }).start();  
}  
@Override  
protected void onDestroy() {  
   super.onDestroy();  
   //终止HandlerThread运行
   myHandlerThread.quit() ;  
}

Q3:用途

  • 进行 串行 异步通信
  • 构造 IntentService
  • 方便实现在子线程(工作线程)中使用 Handler

Q4:原理:

  • 实际就是 HandlerThread.run() 里面封装了 Looper.prepare()Looper.loop() ,以便能在子线程中使用 Handler
  • 同时, HandlerThread.getLooper() 中使用了 wait()和synchronized代码块 ,当 Looper==NULL 的时候,锁住了当前的对象,那什么时候唤醒等待呢?当然是在初始化完该线程关联 Looper 对象的地方,也就是 run()

想了解源码的话,笔者推荐一篇文章: 浅析HandlerThread

2.4.3 IntentService

Q1:定义:

IntentService 是一个继承自 Service 的抽象类

Q2:优点:

  • 相比于线程:由于是服务,优先级比线程高,更不容易被系统杀死。因此较适合执行一些 高优先级 的后台任务
  • 相比于普通 Service :可 自动创建 子线程来执行任务,且任务执行完毕后 自动退出

Q3:使用方法

  • 新建类并继承 IntentService ,重写 onHandleIntent() ,该方法:
Intent
  • 在配置文件中进行注册
  • 在活动中利用 Intent 实现 IntentService 的启动:
Intent intent = new Intent(this, MyService.class);
intent.putExtra("xxx",xxx);  
startService(intent);//启动服务

注意:无需手动停止服务, onHandleIntent() 执行结束之后, IntentService 会自动停止。

Q4:工作原理

  • IntentService.onCreate() 里创建一个 Thread 对象即 HandlerThread ,利用其内部的 Looper 会实例化一个 ServiceHandler
  • 任务请求的 Intent 会被封装到 Message 并通过 ServiceHandler 发送给 LooperMessageQueue ,最终在 HandlerThread 中执行
  • ServiceHandler.handleMessage() 中会调用 IntentService.onHandleIntent() ,可在该方法中处理后台任务的逻辑,执行完毕后会调用 stopSelf() ,以实现自动停止

进阶之路 | 奇妙的Thread之旅

下面继续来研究下:将 Intent 传递给服务 & 依次插入到工作队列中的流程

进阶之路 | 奇妙的Thread之旅

如果对 IntentService 的具体源码感兴趣的话,笔者推荐一篇文章: Android多线程:IntentService用法&源码分析

2.5 线程池

Q1:优点

  • 重用 线程池中的线程,避免线程的创建和销毁带来的性能消耗
  • 有效控制线程池的最大并发数,避免大量的线程之间因互相抢占系统资源而导致阻塞现象
  • 进行 线程管理 ,提供定时/循环间隔执行等功能

Q2:构造方法分析

Executor
ThreadPoolExecutor
//构造参数
public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
  • corePoolSize :核心线程数
  • 默认情况下,核心线程会在线程中一直存活

  • 当设置 ThreadPoolExecutorallowCoreThreadTimeOut 属性为

    A. true :表示核心线程闲置超过超时时长,会被回收

    B. false : 表示核心线程不会被回收,会在线程池中一直存活

  • maximumPoolSize :最大线程数

当活动线程数达到这个数值后,后续的任务将会被阻塞

  • keepAliveTime :非核心线程超时时间
  • 超过这个时长,闲置的非核心线程就会被回收
  • 当设置 ThreadPoolExecutorallowCoreThreadTimeTout 属性为 true 时, keepAliveTime 对核心线程同样有效
  • unit :用于指定 keepAliveTime 参数的时间单位

单位有: TimeUnit.MILLISECONDSTimeUnit.SECONDSTimeUnit.MINUTES 等;

  • workQueue :任务队列

通过线程池的 execute() 方法提交的 Runnable 对象会存储在这个参数中

  • threadFactory :线程工厂,可创建新线程

一个接口,只有一个方法 Thread newThread(Runnable r)

  • handler :在线程池无法执行新任务时进行调度

Q3:ThreadPoolExecutor的默认工作策略

进阶之路 | 奇妙的Thread之旅

​ Q4: 线程池的分类

名称 含义 特点
FixThreadPool 线程数量固定的线程池,所有线程都是 核心线程 ,当线程空闲时 不会 被回收 快速 响应外界请求
CachedThreadPool 线程数量不定的线程池(最大线程数为 Integer.MAX_VALUE ),只有 非核心线程 ,空闲线程有超时机制,超时回收 适合于执行大量的 耗时较少 的任务
ScheduledThreadPool 核心线程数量 固定 ,非核心线程数量 不定 定时 任务和 固定 周期的任务
SingleThreadExecutor 只有 一个核心线程 ,可确保所有的任务都在同一个线程中 按顺序 执行 无需处理 线程同步 问题

三.再聊聊 AsyTask 的不足

AsyncTask 看似十分美好,但实际上存在着非常多的 不足 ,这些不足使得它逐渐退出了历史舞台,因此如今已经被 RxJava协程 等新兴框架所取代(PS:有机会希望能和大家一起探究下 RxJava 的源码)

  • 生命周期

AsyncTask 没有与 ActivityFragment 的生命周期绑定,即使 Activity 被销毁,它的 doInBackground 任务仍然会继续执行

  • 取消任务

AsyncTaskcancel 方法的参数 mayInterruptIfRunning 存在的意义不大,并且它无法保证任务一定能取消,只能尽快让任务取消(比如如果正在进行一些无法打断的操作时,任务就仍然会运行)

  • 内存泄漏
  • 由于它没有与 Activity 等生命周期进行绑定,因此它的生命周期仍然可能比 Activity
  • 如果将它作为 Activity 的非 static 内部类,则它会持有 Activity 的引用,导致 Activity 的内存无法释放。(PS:与 Handler 的内存泄漏问题类似,参考文章: 进阶之路 | 奇妙的Handler之旅
  • 并行/串行

由于 AsyncTask 的串行和并行执行在多个版本上都进行了修改,所以当多个 AsyncTask 依次执行时,它究竟是串行还是并行执行取决于用户手机的版本。具体修改如下:

A. Android 1.6 之前:各个 AsyncTask 按串行的顺序进行执行

B. Android 3.0 之前:由于设计者认为串行执行效率太低,因此改为了并行执行,最多五个 AsyncTask 同时执行

C. Android 3.0 之后:由于之前的改动,很多应用出现了并发问题,因此引入 SerialExecutor 改回了串行执行,但对并行执行进行了支持

如果文章对您有一点帮助的话,希望您能点一下赞,您的点赞,是我前进的动力

本文参考链接:


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

轻营销

轻营销

唐文 / 机械工业出版社 / 2015-6 / 35元

《轻营销》,中国第一本全面讲述如何在互联网新时代用小预算做大营销的书籍,以求把中小微企业从那些以大预算为基础而难以落地的营销理论和案例中解脱出来。用“轻”但真正起作用的方法,帮助传统企业抓住互联网新一波浪潮的机遇,转型升级。 “怒打价格战、拼命砸广告、渠道金字塔”是过去中国企业做营销的基本功课,背后的逻辑是花钱。今天这三招已经不太管用了,广告费用的多少不再是决定性因素。取而代之的是直面客户的......一起来看看 《轻营销》 这本书的介绍吧!

HTML 编码/解码
HTML 编码/解码

HTML 编码/解码

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具