Android Unsafe 分析
来源:互联网 发布:网络宣传计划书范文 编辑:程序博客网 时间:2024/06/14 23:29
1) libcore/libart/src/main/Java/sun/misc/Unsafe.java
2) libcore/libdvm/src/main/java/sun/misc/Unsafe.java
我们平时都知道Java层的代码并不可以直接访问系统底层,这既保障了虚拟机的安全,又屏蔽了底层方便的差异化,可以很好的让程序在上层代码中去实现功能和业务逻辑。不过有时候我们需要直接像C那样子可以直接访问内存里面的内容并且修改其值,这个时候Unsafe类就在关键的时候可以更好的去实现了。Unsafe类提供了硬件级别的原子操作,主要提供了以下的功能:
- 通过Unsafe类可以分配内存
- 可以定位对象某字段的内存位置,并且可以随意的修改其值,只要该字段不是final类型
- 可以随意修改数组元素并且给数组赋值
- 线程的挂起与恢复
- CAS(Compare and Swap)操作
由于Unsafe类是一个比较特殊的,所以并没有出现在JDK文档中,其介绍也非常的好所以一般调用的时候也不会随随便便的让别人调用的,这里我们就需要通过的反射的方式来调用其对象,我们可以通过反射 getUnsafe()方法来获取对象,也可以通过获取 theUnsafe字段并且设置其访问的属性。从代码中我们看到了通过getSafe()方式直接获取Unsafe对象的时候都做了类加载器的判断。所以我们通过反射的方式来获取该对象是比较好的。
/** Traditional dalvik name. */ private static final Unsafe THE_ONE = new Unsafe(); /** Traditional RI name. */ //也可以通过反射该字段并且修改其访问属性为 true。 private static final Unsafe theUnsafe = THE_ONE; private Unsafe() {} // 这里我们可以通过反射的方法获取Unsafe的对象 public static Unsafe getUnsafe() { /* * Only code on the bootclasspath is allowed to get at the * Unsafe instance. */ ClassLoader calling = VMStack.getCallingClassLoader(); if ((calling != null) && (calling != Unsafe.class.getClassLoader())) { throw new SecurityException("Unsafe access denied"); } return THE_ONE; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
创建好了Unsafe对象之后,我们就可以使用 allocateInstance来给某个类创建一个对象,我们也可以说是分配内存空间
/** * Allocates an instance of the given class without running the constructor. * The class' <clinit> will be run, if necessary. */public native Object allocateInstance(Class<?> c);
- 1
- 2
- 3
- 4
- 5
- 6
- 1
- 2
- 3
- 4
- 5
- 6
在创建完对象之后,首先我们就可以通过objectFieldOffset()
获取到该对象中的某个字段的内存地址。
/** * Gets the raw byte offset from the start of an object's memory to * the memory used to store the indicated instance field. * * @param field non-null; the field in question, which must be an * instance field * @return the offset to the field */public long objectFieldOffset(Field field) { if (Modifier.isStatic(field.getModifiers())) { throw new IllegalArgumentException("valid for instance fields only"); } return field.getOffset();}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
在C或者是C++代码中我们可以通过指针来获取某个对象的内存地址来操纵该对象的,在Unsafe中我们使用putInt()
来设置值,使用getInt()
来获取对象的字段值。
更新字段内容方法:
/** * 通过字段的偏移地址给某个对象字段设置对象值,比如说给String类型的字段设置内容。 * 因为String是一个对象并且基本数值 */public native void putObject(Object obj, long offset, Object newValue);/** * 通过字段的偏移地址给某个对象字段设置对象值,比如说给String类型的字段设置内容。 * 因为String是一个对象并且基本数值 */public native void putOrderedObject(Object obj, long offset, Object newValue);public native void putLong(Object obj, long offset, long newValue);public native void putLongVolatile(Object obj, long offset, long newValue);public native void putIntVolatile(Object obj, long offset, int newValue);public native void putOrderedLong(Object obj, long offset, long newValue);public native void putInt(Object obj, long offset, int newValue);/** * 通过字段的便宜地址给int对象字段设置值 * @param obj 需要设置内容对象值 * @param offset 字段的偏移地址 * @param newValue 需要更新的新值 */public native void putOrderedInt(Object obj, long offset, int newValue);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
上述的 putInt() 和 putIntVolatile() 主要区别是是在对象的字段上是否使用了 volatile 个人仅仅只是猜测而已,但是在平时测试的demo中并没有发现什么异常问题而putOrderedInt发现使用起来感觉跟前两个没有什么差别的。这几个方法的用法与差别如果有高手知道的话,可以在评论里大家一起相互交流和学习。
获取字段内容方法
/** * 根据偏移地址来获取obj对象中字段内容 * @param obj 获取内容的对象 * @param offset 字段的偏移地址 */public native long getLong(Object obj, long offset);public native long getLongVolatile(Object obj, long offset);public native int getInt(Object obj, long offset);public native int getIntVolatile(Object obj, long offset);public native Object getObject(Object obj, long offset);public native Object getObjectVolatile(Object obj, long offset);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
通过上面的方法我们清晰的知道了如何创建一个对象并且操作对象的内容了;以前我们在学习C++指针的时候知道数组其实我们可以通过获取数组的首地址,然后在获取某个元素的偏移地址,因为是数组所以其偏移地址都相等的,所以我们就可以递归等值数列来获取每个元素的地址。
/** * Gets the offset from the start of an array object's memory to * the memory used to store its initial (zeroeth) element. * @param clazz non-null; class in question; must be an array class * @return the offset to the initial element */ //获取数组的第一个位置的内存地址 public int arrayBaseOffset(Class clazz) { Class<?> component = clazz.getComponentType(); if (component == null) { throw new IllegalArgumentException("Valid for array classes only: " + clazz); } // TODO: make the following not specific to the object model. int offset = 12; if (component == long.class || component == double.class) { offset += 4; // 4 bytes of padding. } return offset; } /** * Gets the size of each element of the given array class. * * @param clazz non-null; class in question; must be an array class * @return > 0; the size of each element of the array */ //获取数组中每个元素占用的内存大小,我们也可以说是偏移地址。 public int arrayIndexScale(Class clazz) { Class<?> component = clazz.getComponentType(); if (component == null) { throw new IllegalArgumentException("Valid for array classes only: " + clazz); } // TODO: make the following not specific to the object model. if (!component.isPrimitive()) { return 4; } else if (component == long.class || component == double.class) { return 8; } else if (component == int.class || component == float.class) { return 4; } else if (component == char.class || component == short.class) { return 2; } else { // component == byte.class || component == boolean.class. return 1; } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
对于线程的挂起与恢复这里我不做过多的介绍,后面我将会使用一篇专门的博客来介绍LockSupport.java
类,该类其实的主要实现就是使用Unsafe类下面的两个类。
/** * Parks the calling thread for the specified amount of time, * unless the "permit" for the thread is already available (due to * a previous call to {@link #unpark}. This method may also return * spuriously (that is, without the thread being told to unpark * and without the indicated amount of time elapsing). * * @param absolute whether the given time value is absolute * @param time the (absolute millis or relative nanos) time value */ public void park(boolean absolute, long time) { if (absolute) { Thread.currentThread().parkUntil(time); } else { Thread.currentThread().parkFor(time); } } /** * Unparks the given object, which must be a {@link Thread}. * @param obj non-null; the object to unpark */ public void unpark(Object obj) { if (obj instanceof Thread) { ((Thread) obj).unpark(); } else { throw new IllegalArgumentException("valid for Threads only"); } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
下面我们使用Unsafe类中的 compareAndSwapXXX方法来实现并发的操作,其主要的原理是CAS操作,CAS操作也是其他语言并发操作的基本理论,现代CPU广泛的支持的一种对内存中的共享数据进行操作的一种特殊指令,这个指令会对内存中的共享数据做原子的读写操作。CAS基本原理是:CPU会将内存中将要修改的数据会跟我们期望的数据进行比较,如果发现期望的数据跟内存中的不想等说明内存中的数据已经修改过了,这个时候并不会去更新内存的数据;如果两个数据相等的话才会将新的数据去替换内存的旧的数据。CAS有3个操作数,内存地址V,oldValue 旧的预期值(该值主要是用于跟内存中的值就行比对),newValue 需要更新的数据值。只有旧的期望数据跟内存中的数据相等时才将新的数据更新到内存中。这是乐观锁的思路,它一开始就相信其他的线程并没有修改过内存中的内容,直到比对之后才进行操作;但是synchronized是一种悲观所,它认为在修改之前就有其他的线程去修改内存值了,所以就先把其他的线程给挂起,仅仅只让自己一个去修改,效率比较低。对于CAS的更多的例子分析我将会在AtomicInteger
中进行更多的分析和讲解的。
/** * 通过对象字段的偏移地址 offset 来获取该字段的内存数据,然后利用该数据跟 expectedValue * 进行比较,如果相等则更新newValue,并且返回true,否则不更新newValue,并且返回false * @param objec 需要比较的内存对象 * @param offset 对象字段的内存偏移地址 * @param expectedValue 期望值,就是用该值跟内存中的值进行比较 * @param newValue 需要更新的新数据, */ public native boolean compareAndSwapInt(Object obj, long offset, int expectedValue, int newValue); /** * Performs a compare-and-set operation on a <code>long</code> * field within the given object. * * @param obj non-null; object containing the field * @param offset offset to the field within <code>obj</code> * @param expectedValue expected value of the field * @param newValue new value to store in the field if the contents are * as expected * @return <code>true</code> if the new value was in fact stored, and * <code>false</code> if not */ public native boolean compareAndSwapLong(Object obj, long offset, long expectedValue, long newValue); /** * Performs a compare-and-set operation on an <code>Object</code> * field (that is, a reference field) within the given object. * * @param obj non-null; object containing the field * @param offset offset to the field within <code>obj</code> * @param expectedValue expected value of the field * @param newValue new value to store in the field if the contents are * as expected * @return <code>true</code> if the new value was in fact stored, and * <code>false</code> if not */ public native boolean compareAndSwapObject(Object obj, long offset, Object expectedValue, Object newValue);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
代码演示
说了这么多可能都比较抽象什么的,下面我们就直接来演示一下具体的代码实现。首先我们写好反射的工具类 ReflectUtils.java
public class ReflectUtils { private static ReflectUtils sInstance; private ReflectUtils() { } public static ReflectUtils getInstance() { if (sInstance == null) { sInstance = new ReflectUtils(); } return sInstance; } public Method getMethod(Class<?> clazz, String methodName, Class<?> ...values) { Method method = null; try { if(values != null) { method = clazz.getDeclaredMethod(methodName, values); }else { method = clazz.getDeclaredMethod(methodName); } } catch (NoSuchMethodException e) { e.printStackTrace(); } return method; } public Object invokeStaticMethod(Method method, Object ...values) { return invokeMethod(method, null, values); } public Object invokeMethod(Method method, Object classValue, Object ...values) { if(method != null) { try { return method.invoke(classValue, values); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } } return null; } /** * 这里我们通过反射静态方法 getUnsafe()方法来获取Unsafe对象,其实在之前的代码 * 的时候我们也可以直接反射静态的成员变量来直接获取Unsafe对象的,其实都是差不多的。 */ public Object getUnsafe(Class<?> clazz) { Method method = getMethod(clazz, "getUnsafe"); return invokeStaticMethod(method); }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
紧接着我们反射Unsafe类中的方法,因为Unsafe对于我们来说不可见的,所以必须使用方法来实现。
public class UnsafeProxy { private static ReflectUtils sUtils; private static Class sUnsafeClass; private static Object sUnsafe; static { try { sUtils = ReflectUtils.getInstance(); sUnsafeClass = Class.forName("sun.misc.Unsafe"); sUnsafe = sUtils.getUnsafe(sUnsafeClass); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Object allocateInstance(Class<?> params) { Method method = sUtils.getMethod(sUnsafeClass, "allocateInstance", Class.class); return sUtils.invokeMethod(method, sUnsafe, params); } public static long objectFieldOffset(Field field) { Method method = sUtils.getMethod(sUnsafeClass, "objectFieldOffset", Field.class); Object obj = sUtils.invokeMethod(method, sUnsafe, field); return obj == null ? 0 : (Long) obj; } public static long putLong(Object object, long offset, long newValue) { Method method = sUtils.getMethod(sUnsafeClass, "putLong", Object.class, long.class, long.class); Object obj = sUtils.invokeMethod(method, sUnsafe, object, offset, newValue); return obj == null ? 0 : (Long) obj; } public static long putInt(Object object, long offset, int newValue) { Method method = sUtils.getMethod(sUnsafeClass, "putInt", Object.class, long.class, int.class); Object obj = sUtils.invokeMethod(method, sUnsafe, object, offset, newValue); return obj == null ? 0 : (Long) obj; } public static long putObject(Object object, long offset, Object newValue) { Method method = sUtils.getMethod(sUnsafeClass, "putObject", Object.class, long.class, Object.class); Object obj = sUtils.invokeMethod(method, sUnsafe, object, offset, newValue); return obj == null ? 0 : (Long) obj; } public static int arrayIndexScale(Class clazz) { Method method = sUtils.getMethod(sUnsafeClass, "arrayIndexScale", Class.class); Object obj = sUtils.invokeMethod(method, sUnsafe, clazz); return obj == null ? 0 : (Integer) obj; } public static int arrayBaseOffset(Class clazz) { Method method = sUtils.getMethod(sUnsafeClass, "arrayBaseOffset", Class.class); Object obj = sUtils.invokeMethod(method, sUnsafe, clazz); return obj == null ? 0 : (Integer) obj; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
有了上面的准备方法,接下来我们就可以直接使用这些工具方法了,下面我就直接在Activity中调用并且最后将结果打印出来。
public class UnsafeActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); this.setContentView(R.layout.activity_unsage); //首先调用allocateInstance方法创建一个Student对象 Student student2 = (Student) UnsafeProxy.allocateInstance(Student.class); try { Field ageField = student2.getClass().getDeclaredField("age"); Field enterTimeField = student2.getClass().getDeclaredField("entertime"); Field nameField = student2.getClass().getDeclaredField("name"); //获取对象字段的偏移地址 long ageOffset = UnsafeProxy.objectFieldOffset(ageField); long nameOffset = UnsafeProxy.objectFieldOffset(nameField); long entertimeOffset = UnsafeProxy.objectFieldOffset(enterTimeField); //通过偏移地址我们来更新该字段的内存信息 UnsafeProxy.putInt(student2, ageOffset, 100); UnsafeProxy.putLong(student2, entertimeOffset, 121202L); UnsafeProxy.putObject(student2, nameOffset, "王五"); //通过数组的首地址以及每个元素的偏移地址来操作数组元素 String stuNames[] = new String[5]; long firstAddr = UnsafeProxy.arrayBaseOffset(String[].class); long offset = UnsafeProxy.arrayIndexScale(String[].class); //开始操作数组的第一个元素的内容,其首地址是firstAddr。 UnsafeProxy.putObject(stuNames, firstAddr, "张三"); UnsafeProxy.putObject(stuNames, firstAddr + offset, "李四"); UnsafeProxy.putObject(stuNames, firstAddr + 2 * offset, "王五"); UnsafeProxy.putObject(stuNames, firstAddr + 3 * offset, "赵六"); UnsafeProxy.putObject(stuNames, firstAddr + 4 * offset, "孙七"); for(int index = 0; index < stuNames.length; index++) { Log.i("LOH", "method..." + stuNames[index]); } Log.i("LOH", "entertime..." + student2.toString()); } catch (NoSuchFieldException e) { e.printStackTrace(); } } class Student { private String name; private int age; private long entertime; @Override public String toString() { return "Student{" + "name='" + name + '\'' + ", age=" + age + ", entertime=" + entertime + '}'; } }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
PS: 通过上面的代码的代码只是例举了一部分Unsafe的功能,其中操作int[] 数组的元素我还没有成功,具体不知道什么原因导致,还有CAS 和 线程挂起与恢复之类的操作我这里暂时就没有举例,后面我会通过对Atomic系列进行结合的分析。该类可能在我们平时的时候用地方不多,但是对于我们掌握 Java高并发里面核心的API有着非常重大作用,因为Java高并发的API可以说都是基于该类实现的,只不过很多的API做了更多的处理和包装而已,其最核心的东西还是 Unsafe中的 compareAndSwapXXX 系列的方法。其中Android上有一个热修复的框架JAndFix 就是使用的 Unsafe来实现的。
- Android Unsafe 分析
- Android Unsafe 分析
- Unsafe 源码分析
- Unsafe
- Unsafe
- Unsafe
- (转载)AtomicInteger和Unsafe分析
- netty源码分析 之七 transport(Unsafe)
- netty(十四)源码分析之Unsafe
- java unsafe 源码分析详解全面功能
- jdk 源码分析(10)java unsafe 分析
- unsafe关键字
- unsafe & fixed
- UnSafe & Fixed
- unsafe指针
- C#unsafe
- Unsafe初涉
- Unsafe类
- Google面试题 | 目标和
- setsockopt :SO_LINGER 选项设置 .http://blog.csdn.net/factor2000/article/details/3929816?ticket=ST-143210
- 三、Android系统内核编译及刷机实战 (修改反调试标志位)
- android nexus 系列 源码刷机教程(ubuntu)
- 音频技术1
- Android Unsafe 分析
- C++ cin.get()和cin.getline()方法详解及区别
- SIP基本信令
- java:for while do while循环
- 关于Mybatis的There is no getter for property named 'xxxx '错误
- 阿里巴巴天猫技术部-行业&供应链平台-前端招聘 资深前端开发工程师 15k-25k /杭州 / 经验3-5年 / 大专及以上 / 全职
- 【集合类分析】Stack
- Ubuntu下设置tomcat为服务(开机启动)
- css中的px、em、rem 的理解