volatile是Java虚拟机提供的轻量级的同步机制,volatile的特性:保证可见性,不保证原子性,禁止重排序

在介绍volatile之前先了解一个概念:JMM。

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。

JMM(Java内存模型Java Memory Model):

先说计算机系统有包括外存,内存等不同的存储层次,相对完整地来说,计算机存储体系从“外”到“内”分为5层:

  1. 海量外存------存储空间最大,速度最慢
  2. 外存-------存储空间大,速度也比较慢
  3. 内存-------存储空间不是很大,速度却很快
  4. 高速缓存-----存储空间小得多,速度更快
  5. 寄存器-------存储空间最小,速度最快

多核并发缓存架构

  1. L1 Cache(一级缓存)是CPU第一层高速缓存,分为数据缓存和指令缓存。一般服务器CPU的L1缓存的容量通常在32—4096KB。
  2. L2:由于L1级高速缓存容量的限制,为了再次提高CPU的运算速度,在CPU外部放置一高速存储器,即二级缓存。
  3. L3:现在的都是内置的。而它的实际作用即是,L3缓存的应用可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能。具有较大L3缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度。一般是多核共享一个L3缓存!

CPU在读取数据时,先在L1中寻找,再从L2寻找,再从L3寻找,然后是内存,再后是外存储器。

Java线程内存模型跟cpu缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化,抽象示意图如下:

MESI缓存一致性协议:

多CPU读取同样的数据进行缓存,进行不同运算之后,最终写入主内存以哪个CPU为准? 在这种高速缓存回写的场景下,有一个缓存一致性协议,多数CPU厂商对它进行了实现。MESI协议,它规定每条缓存有个状态位,同时定义了下面四个状态:

  1. 修改态(Modified)一此cache行已被修改过(脏行),内容已不同于主存,为此cache专有;
  2. 专有态(Exclusive) 一此cache行内容同于主存,但不出现于其它cache中;
  3. 共享态(Shared)一此cache行内容同于主存,但也出现于其它cache中;
  4. 无效态(Invalid)一此cache行内容无效(空行)。

多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效,从而保证最终一致。通过下面一个例子解释解释总线嗅探机制。

什么是可见性?

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

举个例子解释可见性:

假如现在有两个线程操作同一个myData对象(假设初始时该对象的num成员值为0),两个线程都将主存中的这个对象拷贝到自己的工作内存中去进行操作(此时两个线程的工作内存中的num值为0),现在线程AAA将num值改变,然后刷新到主存中去。主存虽然刷新了,但是其他另一个线程不知道该num的值已经改变。还是在利用各自工作空间的值。所以必须有一种机制,保证只要有一个线程修改完自己工作空间的值,写入到主内存之后,要及时通知其他线程。这种“及时通知”的情况就是JMM内存模型中的一个特性:可见性 

这个博主深入分析了volatile与lock指令。讲的很好https://www.cnblogs.com/badboys/p/12695183.html

验证volatile的可见性:

public class MyData {
	/*volatile*/ int  num = 0;
	
	public void change() {
		num = 60;
	}
	
	public void add() {
		num++;
	}
	
}
public class Test02 {

	public static void main(String[] args) {
		Test02.visibilityTest();
	}

	public static void visibilityTest() {
		MyData myData = new MyData();			
		//创建一个线程,让其休息1s之后修改num的值
		new Thread(new Runnable() {
			
			@Override
			public void run() {
				try {
                                        //注意:休息1s是为了顺利让mian线程的工作内存拷贝到主存中的myData.num值。等他取到了我再修改,我修改成功,它却不知道该值已经变了
					Thread.sleep(1000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				myData.change();
				System.out.println(Thread.currentThread().getName() + "\t :" + myData.num);
			}
		},  "AAA").start();

		while(myData.num == 0) {
			//如果主线程中num的值一直为0,那就不会输出下面那句话
		}
		
		System.out.println("你看我出不出来");
	}
}

在num前不加volatile修饰时,就算等到天荒地老最后一个输出也不会输出出来,因为虽然线程AAA已经将修改后的值写入到主内存中,但是main线程的工作内存中的num值还是0,main线程根本就不知道num值发生改变了啊,没有可见性,所以一直在循环中。
当然 加上volatile之后马上就输出这句话了。因为volatile变量改变之后,其他线程对该共享变量缓存的副本就变为无效,它们会重新去主存读取。

这里volatile保证可见性得到了验证。其实它底层是这样做的:

JMM数据原子操作:

  1. read (读取) :从主内存读取数据
  2. load (载入) :将主内存读取到的数据写入工作内存
  3. use (使用) :从工作内存读取数据来计算
  4. assign (赋值) :将计算好的值重新赋值到工作内存中
  5. store (存储) :将工作内存数据写入主内存
  6. write (写入) :将store过去的变量值赋值给主内存中的变量
  7. lock (锁定) :将主内存变量加锁,标识为线程独占状态
  8. unlock (解锁) : 将主内存变量解锁,解锁后其他线程可以锁定该变量

解释上面总线嗅探机制:上图中线程AAA通过总线刷新num的值,main线程通过总线就可以监听到值变了。然后将自己工作内存中的num值至为无效。然后重新read。这样就拿到新num值了。volatile就是通过这种嗅探机制实现可见性。这是硬件级别的实现
volatile底层汇编角度有一条lock前缀的指令

lock指令的解释如下:

  1. 会将当前处理器缓存行的数据立即写回到系统内存。
  2. 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI)

一些其他操作对内存可见性的影响

下面是对上面代码只修改while循环代码块中的内容,其他地方没有修改。其中成员变量num没有用volatile修饰。

System.out.println影响内存可见性。

while(myData.num == 0) {
    System.out.println("");
}

 循环中加上一个输出立马就能跳出循环了。println这个方法的源码:

 public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

可以看到它是加了锁的。使用了synchronized。

JMM关于同步的规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到自己的工作内存
  3. 加锁解锁是同一把锁

线程加锁前,它会重新从主存里面获取需要的值重新拷贝到工作内存中,所以造成这个影响。

Thread.sleep影响内存可见性:

while(myData.num == 0) {
    try {
        Thread.sleep(1);					//就休息一毫秒
    } catch (InterruptedException e) {
	e.printStackTrace();
    }
}

sleep方法源码是native的看不到,只看结果:

还有一个影响我现在也还不明白为什么(这个问题是在测试ArrayList并发修改异常时发现的),先看例子

public class MyData {
	int  num = 0;
	String[] strArray = new String[5];
	
	public void change() {
		num = 60;
	}
	public void initStr() {
		for(int index = 0; index < 5; index++) {
			strArray[index] = "nihao" + index;
		}
	}
	
	public String getStr(int index) {
		return strArray[index];
	}
}

while循环中加上for循环去获取数组中的值 

while(myData.num == 0) {
    for(int index = 0; index < 2; index++) {
        myData.getStr(index);
    }
}

运行结果充满不确定性。两种情况:

情况一:监测到num值修改,跳出循环

情况二:未监测到num值修改 ,陷入死循环(红框未消失,程序仍然在运行) 

我也还不明白为什么会造成这两种情况,只不过现象确实出现了。 

题外话:至于为什么想这样测试,在学习ArrayList源码时。它的父类AbstractList<E>有一个属性modCount(看了好多博客,好多人说是modCount是被volatile修饰的,至少JDK1.8里面没有),这个成员就是用来检测并发修改异常的。如何造成这种异常?在AbstractList<E>中实现了一个迭代器(内部类的形式  private class Itr implements Iterator<E>),ArrayList对象获取的迭代器对象就是这个类的对象,源码如下:

public Iterator<E> iterator() {
    return new Itr();
}

他是说,一个线程在用迭代器做遍历的时候其他线程不能对ArrayList有结构性的更改(底层数组存储,增删元素就算是结构性更改了,其实主要一点更改了modCount的值),因为这个Itr类的内部有一个成员expectedModCount,在实例化该类的时候这个值就已经被赋值了(赋值为当前modCount的值),然后迭代器取值都会检查modCount是否与expectedModCount相等(如果不想等则抛出并发修改异常java.util.ConcurrentModificationException),所以造成这个异常出现的本质就是那两个值不相等了。

但是但是但是,modCount是没有用volatile修饰的。按照之前的想法,一个线程t1修改了modCount的值,另一个线程t2正在迭代遍历, t2怎么知道modCount值被改了,不应该是t2不可见吗?为了深入一点我将ArrayList和其父类AbstractList的相关代码全部拷贝下来生成自己的类使用,只不过我将Itr类中做了一下微调:

private class Itr implements Iterator<E> {
        int cursor = 0;
        int lastRet = -1;
        int expectedModCount = modCount;

        public boolean hasNext() {
        	return true;						//hasNext修改直接返回true
        }

        public E next() {
            checkForComodification();
            try {
                int i = cursor;
                E next = get(i);			
                lastRet = i;
                cursor = i + 1;
                return next;
            } catch (IndexOutOfBoundsException e) {
                checkForComodification();
               // throw new NoSuchElementException();				//注释起来不让该异常中断main线程
            }
            return null;
        }

        final void checkForComodification() {
            if (modCount != expectedModCount) {
            	throw new ConcurrentModificationException();	
            }
            	
        }
    }

测试:

public static void main(String[] args) {
		ArrayList<Integer> arrayList = new ArrayList<Integer>();
		for(int index = 0; index < 5; index++) {
			arrayList.add(index);
		}
				
		new Thread(()->{
			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			arrayList.add(10);
			System.out.println("添加完毕");
		}).start();
		
		Iterator<Integer> it = arrayList.iterator();
		while(it.hasNext()) {            //这里一直是死循环
			Integer num = it.next();
		}
	}

 来看下结果

 

当添加完毕之后,确实出现异常了。但是为什么我推测是get影响的,如果将get方法注释起来如下:

public E next() {
    checkForComodification();
    try {
        int i = cursor;
//      E next = get(i);			
        lastRet = i;
        cursor = i + 1;
//      return next;
    } catch (IndexOutOfBoundsException e) {
        checkForComodification();
//      throw new NoSuchElementException();				//注释起来不让该异常中断main线程
    }
    return null;
}

 

红框还在,程序还在运行,但是没有出现异常。所以我推测与get有关系。而且MyData那个示例也证明了这一点。

其实测来测去这个并没有什么卵用,因为多线程下根本不会用这种线程不安全的集合,我就是出于好奇。知道有这个事就可以了吧。

但是回到正题,虽然有很多让我捉摸不透的原因,但是最终volatile确实是一定是能保证可见性。

Volatile修饰数组,数组中存储的对象的属性发生更改,多线程可见吗?

用volatile修饰数组,最基本一点数组本身的引用发生变化,其他线程肯定是可见的。现在是,数组本身的引用不发生变化,只改变其中存储的元素(自定义类型的引用)的某个属性值,它是不是对多线程可见?

public class User {
	private  String name;
	private int age;

	public User(String name, int age) {
		this.name = name;
		this.age = age;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}
}
public class UserArray {
	private /*volatile*/ User[] users = new User[1];
	
	public UserArray() {}

	public void add(User u) {
		users[0] = u;
	}
	
	public User get() {
		return users[0];
	}
}

 测试:

public static void main(String[] args) {
    UserArray userArray = new UserArray();
		
    userArray.add(new User("杨达", 0));
		
    new Thread(()->{
        try {
              Thread.sleep(1000);
        } catch (InterruptedException e) {
                e.printStackTrace();
        }

        User u = userArray.get();
        u.setAge(60); 		        //没有改变User对象的引用,只是改变一下该对象的属性成员
    }).start();
		
    while(userArray.get().getAge() == 0) {}
		
    System.out.println("你看我出不出来");
}

我分别对users加volatile和不加的情况下做了实验 。有两种情况

  1. 没有加volatile,没输出
  2. 加了volatile,有输出

之前我以为就算加上volatile,那么只是数组本身的引用发生变化才对其他线程可见。所以查阅了一些资料

If Thread A reads a volatile variable, then all all variablesvisible to 
Thread A when reading the volatile variable will also be re-read from main memory.

简单翻译一下,就是当一个线程在读取一个使用volatile修饰的变量的时候,会将该线程中所有使用的变量从主内存中重新读取。

注意:上面代码中while循环中的User对象都是不断从userArray中获取到最新的。如果变成下面这样:

User user = userArray.get();
while(user.getAge() == 0) {}
		
System.out.println("你看我出不出来");

答案是不出来。  所以上述英文那段话应该有所体会了,所以只要数组引用被volatile修饰了,多线程获取其元素值,获取到的也是最新的。 因为age本身不是被volatile修饰的,但是如果age成员加上volatile,那就出来了。

什么是原子性?

原子性表示不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被阻塞或者被分割。需要整体完整要么同时成功,要么同时失败。

volatile不保证原子性:

示例MyData类中num成员现在是被volatile修饰的。

	public static void atomicityTest() {
		MyData myData = new MyData();				
		
		//开启20个线程操作共享数据,  此时num前面是用volatile修饰的
		for(int index = 0; index < 20; index++) {
			new Thread(new Runnable() {
				
				@Override
				public void run() {
					for(int index = 0; index < 1000; index++) {
						myData.add();	
					}
				}
			}).start();
		}
		
		/*
		 * 等20个线程都执行操作完毕之后  main线程获取num的值。如果volatile可以保证原子性,那么最后取得的值一定是20000。
		 * 如果值不对,那就说明num++不是原子操作
		 */
		while(Thread.activeCount() > 1) {
			//这块好多人是写的2,说是main线程和gc线程,我觉得gc线程只有触发gc的时候才有吧。总之输出是没问题的
			Thread.yield();
		}
		//如果只剩main线程则打印
		System.out.println(Thread.currentThread().getName() + "   : " + myData.num);		//两次输出17844   18951(数据丢失)
	}

 解释:这一条代码num++,CPU执行的时候他会被拆分成了多条指令。

通过看字节码就能知道:

假设有三个线程t1,t2,t3。现在主内存中的num值为0,t1,t2,t3都通过“读指令”读取到这个0,进行自增操作。他们自增完之后,各自工作内存中的num值都为1,假设他们现在各自要将新值写入到主存中了(即执行“更新值”的“指令”)CPU分配给t1,t2的时间片段到,t3成功将1写入到主存中,写入完毕,t1,t2获得执行权,接着又将1写入主存,其实这里本来应该写入3,但是就是因为这几条指令不是原子地操作,所以导致了部分数据丢失。

上述例子中要保证原子性,可以利用同步机制,如下:

public void run() {
    for(int index = 0; index < 1000; index++) {
	synchronized (myData) {
	    myData.add();
        }
    }
}

这样就不会造成数据丢失了,因为同步执行时它其实是多线程串行执行同步代码块中的代码。那么  num++操作  即“读取值的指令,加1的指令,更新值的指令”这三条指令。t1先执行,整个执行完毕之后,t2才能执行。所以可以避免上述问题。

但是synchronized比较重。可以使用带原子操作的整形量AtomicInteger,如下。

public class MyData {

	//不指定值默认初值为0   AtomicInteger是带原子性操作的。 
	AtomicInteger number = new AtomicInteger();
	
	public void addNumber() {
		number.getAndIncrement();
	}
	
}

AtomicInteger从字面意思就能知道它是带原子操作的。所以使用它也能保证原子性。

什么是重排序?

所有高级程序设计语言所编写的源代码都要经过编译系统或解释系统翻译。转换为计算机硬件系统能够识别的机器码,最终执行。由于现代编译或解释软件很强大,很智能,他们会尽可能地让我们的程序以最高效的形式工作,他们会尽可能地“优化”我们的代码,最终使得编译或解释出的机器语言与我们的源代码有所差异。

其实重排序也分为3种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:

上图的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。

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

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

数据依赖性

编译器和处理器在进行重排序时必须要考虑指令之间的数据依赖性,不会改变存在数据依赖关系的两个操作的执行顺序。

(下面例子中一条语句可能对应着多条指令。为了方便解释就将一条语句当成一条指令来解释)

public class MySort {

	public static void main(String[] args) {
		int x = 11;				//语句1
		int y = 15;				//语句2
		x = x + 2;				//语句3
		y = x * x;				//语句4
	}

}

上述例子中 不进行指令重排的情况下执行顺序是:语句1 语句2 语句3 语句4

若进行指令重排那情况就多了:2134可以     1324也可以。但是语句4永远都不可能放在第一条执行,原因就是语句4中的y是要依赖x来计算的。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义、编译器和处理器不会对存在数据依赖关系的操作做重排序。因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按照程序的顺序来执行的。as-if-serial语义使得单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

volatile可以禁止重排序

DCL (双端检锁)机制也不一定安全(重排序影响)

来通过一个例子来分析:

public class Singleton {
	private static /*volatile*/ Singleton instance = null;
	
	private Singleton() {}
	
	//DCL (Double Check Lock 双端检锁机制)加锁之前和加锁之后各检查一次
	public static Singleton getInstance() {
		if(instance == null) {
			synchronized (Singleton.class) {
				if(instance == null) {
					instance = new Singleton();
				}
			}
		}
		
		return instance;
	}
}

分析上面例子,由于指令重排的存在,某一个线程在执行到第一次检测的时候,读取到的instance不为null时,instance可能还没有初始化完成!

instance = new Singleton();这句话可以分三步完成:

  1. memory = allocate()    //分配对象内存空间
  2. instance(memory);    //初始化对象
  3. instance = memory;    //设置instance指向刚分配的内存地址,此时instance != null

步骤2和步骤3不存在数据依赖关系(内存已经开辟出来了我可以先确定指向关系再初始化,也可以先初始化在确定指向关系),而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是在多线程并发环境下,那可不行。

如果是  1  3   2的情况。一个线程进来了执行instance = new Singleton();  按照1 3 2的顺序,刚刚执行完3, instance 表面上不为 null了,但是他没有初始化完成啊!!!  另外一个线程抢到执行权,判断instance不为null(其实还没有初始化完成),直接return,它所以最终得到的对象将是null。

happens-before先行发生原则

happens-before用于指定两个操作之间的执行顺序。由于这里两个操作既可以是一个线程之内,也可以是在不同线程之间。因此JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系来指定的顺序来执行。如果重排序之后的执行结果,与按照happens-before关系来执行的结果一直,那么这种重排序并不非法(即JMM允许这种重排序)。

happens-before规则

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后序操作。
  2. 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么 A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中任意操作happens-before于线程A从ThreadB.join()操作成功返回。

final域

final域的重排序规则:

对于final域,编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这可以保证在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。这个规则的实现包含下面2个方面。

  1. JMM禁止编译器把final域的写重排序到构造函数之外。
  2. 编译器会在final域的写之后,构造函数的return之前,插入一个StoreStore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外。

读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作。编译器会在读final域操作前面插入一个LoadLoad屏障。初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守简介依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序,这个规则就是专门用来针对这种处理器的。读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象引用。

 

 


 

 

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐