0%

什么是注解

注解对于开发人员来讲既熟悉又陌生,熟悉是因为只要你是做开发,都会用到注解(常见的@Override);陌生是因为即使不使用注解也照常能够进行开发;注解不是必须的,但了解注解有助于我们深入理解某些第三方框架(比如Android Support Annotations、JUnit、xUtils、ActiveAndroid等),提高工作效率。

Java注解又称为标注,是Java从1.5开始支持加入源码的特殊语法元数据;Java中的类、方法、变量、参数、包都可以被注解。这里提到的元数据是描述数据的数据,结合实例来说明:

1
<string name="app_name">AnnotionDemo</string>

这里的”app_name”就是描述数据”AnnotionDemo”的数据,这是在配置文件中写的,注解是在源码中写的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main_layout);
new Thread(new Runnable(){
@Override
public void run(){
setTextInOtherThread();
}
}).start();
}

在上面的代码中,在MainActivity.java中复写了父类Activity.java的onCreate方法,使用到了@Override注解。但即使不加上@Override注解标记代码,程序也能够正常运行。那这里的@Override注解有什么用呢?使用它有什么好处?事实上,@Override是告诉编译器这个方法是一个重写方法,如果父类中不存在该方法,编译器会报错,提示该方法不是父类中的方法。如果不小心拼写错误,将onCreate写成了onCreat,而且没有使用@Override注解,程序依然能够编译通过,但运行结果和期望的大不相同。从示例可以看出,注解有助于阅读代码。

使用注解很简单,根据注解类的@Target所修饰的对象范围,可以在类、方法、变量、参数、包中使用“@+注解类名+[属性值]”的方式使用注解。比如:

1
2
3
4
5
@UiThread
private void setTextInOtherThread(@StringRes int resId){
TextView threadTxtView = (TextView)MainActivity.this.findViewById(R.id.threadTxtViewId);
threadTxtView.setText(resId);
}

特别说明:

  • 注解仅仅是元数据,和业务逻辑无关,所以当你查看注解类时,发现里面没有任何逻辑处理;
  • javadoc中的@author、@version、@param、@return、@deprecated、@hide、@throws、@exception、@see是标记,并不是注解;

注解的作用

  • 格式检查:告诉编译器信息,比如被@Override标记的方法如果不是父类的某个方法,IDE会报错;
  • 减少配置:运行时动态处理,得到注解信息,实现代替配置文件的功能;
  • 减少重复工作:比如第三方框架xUtils,通过注解@ViewInject减少对findViewById的调用,类似的还有(JUnit、ActiveAndroid等);

注解是如何工作的?

注解仅仅是元数据,和业务逻辑无关,所以当你查看注解类时,发现里面没有任何逻辑处理,eg:

1
2
3
4
5
6
7
8
9
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ViewInject {

int value();

/* parent view id */
int parentId() default 0;
}

如果注解不包含业务逻辑处理,必然有人来实现这些逻辑。注解的逻辑实现是元数据的用户来处理的,注解仅仅提供它定义的属性(类/方法/变量/参数/包)的信息,注解的用户来读取这些信息并实现必要的逻辑。当使用java中的注解时(比如@Override、@Deprecated、@SuppressWarnings)JVM就是用户,它在字节码层面工作。如果是自定义的注解,比如第三方框架ActiveAndroid,它的用户是每个使用注解的类,所有使用注解的类都需要继承Model.java,在Model.java的构造方法中通过反射来获取注解类中的每个属性:

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
public TableInfo(Class<? extends Model> type) {
mType = type;

final Table tableAnnotation = type.getAnnotation(Table.class);

if (tableAnnotation != null) {
mTableName = tableAnnotation.name();
mIdName = tableAnnotation.id();
}
else {
mTableName = type.getSimpleName();
}

// Manually add the id column since it is not declared like the other columns.
Field idField = getIdField(type);
mColumnNames.put(idField, mIdName);

List<Field> fields = new LinkedList<Field>(ReflectionUtils.getDeclaredColumnFields(type));
Collections.reverse(fields);

for (Field field : fields) {
if (field.isAnnotationPresent(Column.class)) {
final Column columnAnnotation = field.getAnnotation(Column.class);
String columnName = columnAnnotation.name();
if (TextUtils.isEmpty(columnName)) {
columnName = field.getName();
}

mColumnNames.put(field, columnName);
}
}

}

注解和配置文件的区别

通过上面的描述可以发现,其实注解干的很多事情,通过配置文件也可以干,比如为类设置配置属性;但注解和配置文件是有很多区别的,在实际编程过程中,注解和配置文件配合使用在工作效率、低耦合、可拓展性方面才会达到权衡。

配置文件:

使用场合:

  • 外部依赖的配置,比如build.gradle中的依赖配置;
  • 同一项目团队内部达成一致的时候;
  • 非代码类的资源文件(比如图片、布局、数据、签名文件等);

优点:

  • 降低耦合,配置集中,容易扩展,比如Android应用多语言支持;
  • 对象之间的关系一目了然,比如strings.xml;
  • xml配置文件比注解功能齐全,支持的类型更多,比如drawable、style等;

缺点:

  • 繁琐;
  • 类型不安全,比如R.java中的都是资源ID,用TextView的setText方法时传入int值时无法检测出该值是否为资源ID,但@StringRes可以;

注解:

使用场合:

  • 动态配置信息;
  • 代为实现程序逻辑(比如xUtils中的@ViewInject代为实现findViewById);
  • 代码格式检查,比如Override、Deprecated、NonNull、StringRes等,便于IDE能够检查出代码错误;

优点:

  • 在class文件中,提高程序的内聚性;
  • 减少重复工作,提高开发效率,比如findViewById。

缺点:

  • 如果对annotation进行修改,需要重新编译整个工程;
  • 业务类之间的关系不如XML配置那样一目了然;
  • 程序中过多的annotation,对于代码的简洁度有一定影响;
  • 扩展性较差;

常见注解

API

Android开发过程中使用到的注解主要来自如下几个地方:

  • Android SDK:在包android.annotation下;
  • Android Annotation Support包:在包android.support.annotation下;
  • JDK:在包java.lang下;
  • 第三方框架中的自定义注解;

最常见注解

@Override

属于标记注解,不需要设置属性值;只能添加在方法的前面,用于标记该方法是复写的父类中的某个方法,如果在父类没有的方法前面加上@Override注解,编译器会报错:

1
2
3
4
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

@Deprecated

&emsp;属于标记注解,不需要设置属性值;可以对构造方法、变量、方法、包、参数标记,告知用户和编译器被标记的内容已不建议被使用,如果被使用,编译器会报警告,但不会报错,程序也能正常运行:

1
2
3
4
5
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.CONSTRUCTOR, ElementType.FIELD, ElementType.LOCAL_VARIABLE, ElementType.METHOD, ElementType.PACKAGE, ElementType.PARAMETER, ElementType.TYPE})
public @interface Deprecated {
}

@SuppressWarnings

&emsp;可以对构造方法、变量、方法、包、参数标记,用于告知编译器忽略指定的警告,不用再编译完成后出现警告信息:

1
2
3
4
5
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.CONSTRUCTOR, ElementType.LOCAL_VARIABLE})
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings {
String[] value();
}

@TargetApi

可以对接口、方法、构造方法标记,如果在应用中指定minSdkVersion为8,但有地方需要使用API 11中的方法,为了避免编译器报错,在调用API11中方法的接口、方法或者构造方法前面加上@Target(11),这样该方法就可以使用<=11的API接口了。虽然这样能够避免编译器报错,但在运行时需要注意,不能在API低于11的设备中使用该方法,否则会crash(可以获取程序运行设备的API版本来判断是否调用该方法):

1
2
3
4
5
6
7
8
@Target({TYPE, METHOD, CONSTRUCTOR})
@Retention(RetentionPolicy.CLASS)
public @interface TargetApi {
/**
* This sets the target api level for the type..
*/
int value();
}

@SuppressLint

和@Target的功能差不多,但使用范围更广,主要用于避免在lint检查时报错:

1
2
3
4
5
6
7
8
9
@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE})
@Retention(RetentionPolicy.CLASS)
public @interface SuppressLint {
/**
* The set of warnings (identified by the lint issue id) that should be
* ignored by lint. It is not an error to specify an unrecognized name.
*/
String[] value();
}

Android Annotation Support包中的注解介绍:

Android support library从19.1版本开始引入了一个新的注解库,它包含很多有用的元注解,你能用它们修饰你的代码,帮助你发现bug。Support library自己本身也用到了这些注解,所以作为support library的用户,Android Studio已经基于这些注解校验了你的代码并且标注其中潜在的问题。

这些注解是作为一个support包提供给开发者使用,要使用他们,需要在build.gradle中添加对android support-annotations的依赖:

1
compile 'com.android.support:support-annotations:22.2.0'

support包中的注解分为如下几大类:

  • Nullness注解:

@Nullable:用于标记方法参数或者返回值可以为空;

@NonNull:用于标记方法参数或者返回值不能为空,如果为空编译器会报警告;

  • 资源类型注解:

这类注解主要用于标记方法的参数必须要是指定的资源类型,如果不是,IDE就会报错;因为资源文件都是静态的,所以在编写代码时IDE就知道传值是否错误,可以避免传的资源id错误导致运行时异常。资源类型注解包括@AnimatorRes、@AnimRes、@AnyRes、@ArrayRes、@BoolRes、@ColorRes、@DimenRes、@DrawableRes、@FractionRes、@IdRes、@IntgerRes、@InterpolatorRes、@LayoutRes、@MenuRes、@PluralsRes、@RawRes、@StringRes、@StyleableRes、@StyleRes、@TransitionRes、@XmlRes。

  • 类型定义注解:

这类注解用于检查“魔幻数”,很多时候,我们使用整型常量代替枚举类型(性能考虑),例如我们有一个IceCreamFlavourManager类,它具有三种模式的操作:VANILLA,CHOCOLATE和STRAWBERRY。我们可以定义一个名为@Flavour的新注解,并使用@IntDef指定它可以接受的值类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class IceCreamFlavourManager {
private int flavour;

public static final int VANILLA = 0;
public static final int CHOCOLATE = 1;
public static final int STRAWBERRY = 2;

@IntDef({VANILLA, CHOCOLATE, STRAWBERRY})
public @interface Flavour {
}

@Flavour
public int getFlavour() {
return flavour;
}

public void setFlavour(@Flavour int flavour) {
this.flavour = flavour;
}
}

这时如果我们使用错误的整型值调用IceCreamFlavourManager.setFlavour时,IDE将报错如下:

img

wrong_flavour_error

IDE甚至会提示我们可以使用的有效的取值:

img

ide_suggests_flavours

我们也可以指定整型值作为标志位,也就是说这些整型值可以使用’|’或者’&’进行与或等操作。如果我们把@Flavour定义为如下标志位:

1
2
3
@IntDef(flag = true, value = {VANILLA, CHOCOLATE, STRAWBERRY})
public @interface Flavour {
}

那么可以如下调用:

1
iceCreamFlavourManager.setFlavour(IceCreamFlavourManager.VANILLA & IceCreamFlavourManager.CHOCOLATE);
  • 线程注解:

用于标记指定的方法、类(如果一个类中的所有方法都有相同的线程需求,就可以对这个类进行注解,比如View.java就被@UIThread所标记)只能在指定的线程类中被调用,包括:@UiThread、@MainThread、@WorkerThread、@BinderThread;以@UIThread为例,说明这类注解的使用方法:

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
public class MainActivity extends Activity{

@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main_layout);
new Thread(new Runnable(){
@Override
public void run(){
setTextInOtherThread(R.string.app_name);
// setTextInOtherThread2(R.string.app_name);
}
}).start();
}

@UiThread
private void setTextInOtherThread(@StringRes int resId){
TextView threadTxtView = (TextView)MainActivity.this.findViewById(R.id.threadTxtViewId);
threadTxtView.setText(resId);
}

private void setTextInOtherThread2(@StringRes final int resId){
MainActivity.this.runOnUiThread(new Runnable(){
@Override
public void run(){
TextView threadTxtView = (TextView)MainActivity.this.findViewById(R.id.threadTxtViewId);
threadTxtView.setText(resId);
}
});
}
}

@UIThread和@MainThread的区别:在进程里只有一个主线程。这个就是@MainThread。同时这个线程也是一个@UiThread。比如activity的主要窗口就运行在这个线程上。然而它也有能力为应用创建其他线程。这很少见,一般具备这样功能的都是系统进程。通常是把和生命周期有关的用@MainThread标注,和View层级结构相关的用@UiThread标注。但是由于@MainThread本质上是一个@UiThread,而大部分情况下@UiThread又是一个@MainThread,所以工具(lint ,Android Studio,等等)可以把他们互换,所以你能在一个可以调用@MainThread方法的地方也能调用@UiThread方法,反之亦然。

  • GRB颜色值注解:

用于标记传递的颜色值必须是整型值,并且不能是color资源ID;当你的API期望一个颜色资源的时候,可以用@ColorRes标注,但是当你有一个相反的使用场景时,这种用法就不可用了,因为你并不是期望一个颜色资源id,而是一个真实的RGB或者ARGB的颜色值。在这种情况下,你可以使用@ColorInt注解,表示你期望的是一个代表颜色的整数值:

1
public void setTextColor(@ColorInt int color);

有了这个,当你传递一个颜色id而不是颜色值的时候,lint就会标记出这段不正确的代码:

img

ColorInf

  • 值约束注解:

用于标记参数必须是指定类型的值,并且值的范围必须在约束的范围内,包括@Size、@IntRange、@FloatRange。如果你的参数是一个float或者double类型,并且一定要在某个范围内,你可以使用@FloatRange注解:

1
2
3
public void setAlpha(@FloatRange(from=0.0, to=1.0) float alpha){
...
}

如果有人使用该API的时候传递一个0-255的值,比如尝试调用setAlpha(128),那么工具就会捕获这一问题:

img

值约束错误

把这些注解应用到参数上是非常有用的,因为用户很有可能会提供错误范围的参数,比如上面的setAlpha例子,有的API是采用0-255的方式,而有的是采用0-1的float值的方式。

对于数据、集合以及字符串,你可以用@Size注解参数来限定集合的大小(当参数是字符串的时候,可以限定字符串的长度)。举几个例子:

1、集合不能为空: @Size(min=1);

2、字符串最大只能有23个字符: @Size(max=23);

3、数组只能有2个元素: @Size(2);

4、数组的大小必须是2的倍数 (例如图形API中获取位置的x/y坐标数组: @Size(multiple=2)。

img

Size注解

  • 权限注解:

如果你的方法需要调用者有特定的权限,你可以使用@RequiresPermission注解:

1
2
@RequiresPermission(Manifest.permission.SET_WALLPAPER)
public abstract void setWallpaper(Bitmap bitmap) throws IOException;

如果你至少需要权限集合中的一个,你可以使用anyOf属性:

1
2
3
4
@RequiresPermission(anyOf = {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION})
public abstract Location getLastKnownLocation(String provider);

如果你同时需要多个权限,你可以用allOf属性:

1
2
3
4
@RequiresPermission(allOf = {
Manifest.permission.READ_HISTORY_BOOKMARKS,
Manifest.permission.WRITE_HISTORY_BOOKMARKS})
public static final void updateVisitedHistory(ContentResolver cr, String url, boolean real) {

对于intents的权限,可以直接在定义的intent常量字符串字段上标注权限需求(他们通常都已经被@SdkConstant注解标注过了):

1
2
3
@RequiresPermission(android.Manifest.permission.BLUETOOTH)
public static final String ACTION_REQUEST_DISCOVERABLE =
"android.bluetooth.adapter.action.REQUEST_DISCOVERABLE";

对于content providers的权限,你可能需要单独的标注读和写的权限访问,所以可以用@Read或者@Write标注每一个权限需求:

1
2
3
@RequiresPermission.Read(@RequiresPermission(READ_HISTORY_BOOKMARKS))
@RequiresPermission.Write(@RequiresPermission(WRITE_HISTORY_BOOKMARKS))
public static final Uri BOOKMARKS_URI = Uri.parse("content://browser/bookmarks");

img

  • 复写方法注解:

如果你的API允许使用者重写你的方法,但你又需要你自己的方法(父方法)在重写的时候也被调用,这时候你可以使用@CallSuper标注:

1
2
@CallSuper
protected void onCreate(@Nullable Bundle savedInstanceState) {

用了这个后,当重写的方法没有调用父方法时,工具就会给予警告提示:

img

  • 返回值注解:

如果你的方法有返回值,你期望调用者用这个值做些事情,那么你可以使用@CheckResult注解标注这个方法。

你并不需要为每个非空方法都进行标注。它主要的目的是帮助哪些容易被混淆,难以被理解的API的使用者。

比如,可能很多开发者都对String.trim()一知半解,认为调用了这个方法,就可以让字符串改变以去掉空白字符。如果这个方法被@CheckResult标注,工具就会对那些没有使用trim()返回结果的调用者发出警告。

Android中,Context#checkPermission这个方法已经被@CheckResult标注了:

1
2
@CheckResult(suggest="#enforcePermission(String,int,int,String)")
public abstract int checkPermission(@NonNull String permission, int pid, int uid);

这是非常重要的,因为有些使用context.checkPermission的开发者认为他们已经执行了一个权限 —-但其实这个方法仅仅只做了检查并且反馈一个是否成功的值而已。如果开发者使用了这个方法,但是又不用其返回值,那么这个开发者真正想调用的可能是这个Context#enforcePermission方法,而不是checkPermission。

img

  • 测试可见注解:

你可以把这个注解标注到类、方法或者字段上,以便你在测试的时候可以使用他们。

自定义注解

通过阅读注解类的源码可以发现,任何一个注解类都有如下特征:

  • 注解类会被@interface标记;
  • 注解类的顶部会被@Documented、@Retention、@Target、@Inherited这四个注解标记(@Documented、@Inherited可选,@Retention、@Target必须要有);

@UiThread源码:

1
2
3
4
5
@Documented
@Retention(CLASS)
@Target({METHOD,CONSTRUCTOR,TYPE})
public @interface UiThread {
}

元注解

上文提到的四个注解:@Documented、@Retention、@Target、@Inherited就是元注解,它们的作用是负责注解其它注解,主要是描述注解的一些属性,任何注解都离不开元注解(包括元注解自身,通过元注解可以自定义注解),元注解的用户是JDK,JDK已经帮助我们实现了这四个注解的逻辑。这四个注解在JDK的java.lang.annotation包中。对每个元注解的详细说明如下:

  • @Target:

作用:用于描述注解的使用范围,即被描述的注解可以用在什么地方;

取值:

1、CONSTRUCTOR:构造器;

2、FIELD:实例;

3、LOCAL_VARIABLE:局部变量;

4、METHOD:方法;

5、PACKAGE:包;

6、PARAMETER:参数;

7、TYPE:类、接口(包括注解类型) 或enum声明。

示例:

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
/***
*
* 实体注解接口
*/
@Target(value = {ElementType.TYPE})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface Entity {
/***
* 实体默认firstLevelCache属性为false
* @return boolean
*/
boolean firstLevelCache() default false;
/***
* 实体默认secondLevelCache属性为false
* @return boolean
*/
boolean secondLevelCache() default true;
/***
* 表名默认为空
* @return String
*/
String tableName() default "";
/***
* 默认以""分割注解
*/
String split() default "";
}
  • @Retention:

作用:表示需要在什么级别保存该注解信息,用于描述注解的生命周期,即被描述的注解在什么范围内有效;

取值:

1、SOURCE:在源文件中有效,即源文件保留;

2、CLASS:在class文件中有效,即class保留;

3、RUNTIME:在运行时有效,即运行时保留;

示例:

1
2
3
4
5
6
7
8
/***
* 字段注解接口
*/
@Target(value = {ElementType.FIELD})//注解可以被添加在实例上
@Retention(value = RetentionPolicy.RUNTIME)//注解保存在JVM运行时刻,能够在运行时刻通过反射API来获取到注解的信息
public @interface Column {
String name();//注解的name属性
}
  • @Documented:

作用:用于描述其它类型的annotation应该被作为被标注的程序成员的公共API,因此可以被例如javadoc此类的工具文档化。

取值:它属于标记注解,没有成员;

示例:

1
2
3
4
5
@Documented
@Retention(CLASS)
@Target({METHOD,CONSTRUCTOR,TYPE})
public @interface UiThread {
}
  • @Inherited:

作用:用于描述某个被标注的类型是可被继承的。如果一个使用了@Inherited修饰的annotation类型被用于一个class,则这个annotation将被用于该class的子类。

取值:它属于标记注解,没有成员;

示例:

1
2
3
4
5
6
7
8
9
/**  
* @author wangsheng
**/
@Inherited
public @interface Greeting {
public enum FontColor{ BULE,RED,GREEN};
String name();
FontColor fontColor() default FontColor.GREEN;
}

如何自定义注解

使用@interface自定义注解时,自动继承了java.lang.annotation.Annotation接口,由编译程序自动完成其他细节。在定义注解时,不能继承其他的注解或接口。@interface用来声明一个注解,其中的每一个方法实际上是声明了一个配置参数。方法的名称就是参数的名称,返回值类型就是参数的类型(返回值类型只能是基本类型、Class、String、enum)。可以通过default来声明参数的默认值。

  • 自定义注解格式:
1
2
3
4
元注解
public @interface 注解名{
定义体;
}
  • 注解参数可支持的数据类型:

1、所有基本数据类型(int,float,boolean,byte,double,char,long,short);

2、String类型;

3、Class类型;

4、enum类型;

5、Annotation类型;

6、以上所有类型的数组。

特别说明:

1、注解类中的方法只能用public或者默认这两个访问权修饰,不写public就是默认,eg:

1
2
3
4
5
6
7
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FruitColor {
public enum Color{ BULE,RED,GREEN};
Color fruitColor() default Color.GREEN;
}

2、如果注解类中只有一个成员,最好把方法名设置为”value”,比如:

1
2
3
4
5
6
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface FruitName {
String value() default "";
}

3、注解元素必须有确定的值,要么在定义注解的默认值中指定,要么在使用注解时指定,非基本类型的注解元素的值不可为null。因此, 使用空字符串或0作为默认值是一种常用的做法。

  • 实例演示:

ToDo.java:注解类

1
2
3
4
5
6
7
8
9
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface Todo {
public enum Priority {LOW, MEDIUM, HIGH}
public enum Status {STARTED, NOT_STARTED}
String author() default "Yash";
Priority priority() default Priority.LOW;
Status status() default Status.NOT_STARTED;
}

BusinessLogic:使用注解的类

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
public class BusinessLogic {
public BusinessLogic() {
super();
}

public void compltedMethod() {
System.out.println("This method is complete");
}

@Todo(priority = Todo.Priority.HIGH)
public void notYetStartedMethod() {
// No Code Written yet
}

@Todo(priority = Todo.Priority.MEDIUM, author = "Uday", status = Todo.Status.STARTED)
public void incompleteMethod1() {
//Some business logic is written
//But its not complete yet
}

@Todo(priority = Todo.Priority.LOW, status = Todo.Status.STARTED )
public void incompleteMethod2() {
//Some business logic is written
//But its not complete yet
}
}

TodoReport.java:解析注解信息

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
public class TodoReport {
public TodoReport() {
super();
}

public static void main(String[] args) {
getTodoReportForBusinessLogic();
}

/**
* 解析使用注解的类,获取通过注解设置的属性
*/
private static void getTodoReportForBusinessLogic() {
Class businessLogicClass = BusinessLogic.class;
for(Method method : businessLogicClass.getMethods()) {
Todo todoAnnotation = (Todo)method.getAnnotation(Todo.class);
if(todoAnnotation != null) {
System.out.println(" Method Name : " + method.getName());
System.out.println(" Author : " + todoAnnotation.author());
System.out.println(" Priority : " + todoAnnotation.priority());
System.out.println(" Status : " + todoAnnotation.status());
System.out.println(" --------------------------- ");
}
}
}
}

执行结果如下图所示:

img

注解Demo执行结果


本文整理自

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


在开发中,我们经常使用 HashMap 容器来存储 K-V 键值对,但是在并发多线程的情况下,HashMap 容器又是不安全的,因为在 put 元素的时候,如果触发扩容操作,也就是 rehash ,就会将原数组的内容重新 hash 到新的扩容数组中,但是在扩容这个过程中,其他线程也在进行 put 操作,如果这两个元素 hash 值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的

那有没有安全的 Map 容器呢?有的,目前 JDK 中提供了三种安全的 Map 容器:

  • HashTable
  • Collections.SynchronizedMap(同步包装器提供的方法)
  • ConcurrentHashMap

先来看看前两种容器,它们都是利用非常粗粒度的同步方式,在高并发情况下,性能比较低下。Hashtable 是在 put、get、size 等各种方法加上“synchronized” 锁来保证安全,这就导致了所有并发操作都要竞争同一把锁,一个线程在进行同步操作时,其他线程只能等待,大大降低了并发操作的效率。

再来看看 Collections 提供的同步包装器 SynchronizedMap ,我们可以先来看看 SynchronizedMap 的源代码:

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
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;

private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize

SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}

SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}

public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
public void putAll(Map<? extends K, ? extends V> map) {
synchronized (mutex) {m.putAll(map);}
}
public void clear() {
synchronized (mutex) {m.clear();}
}
......
}

从源码中,我们可以看出 SynchronizedMap 虽然方法没有加 synchronized 锁,但是利用了“this”作为互斥的 mutex,所以在严格意义上 SynchronizedMap 跟 HashTable 一样,并没有实际的改进。

第三个 ConcurrentHashMap 也是这篇文章的主角,它相对前两种安全的 Map 容器来说,在设计和思想上有较大的变化,也极大的提高了 Map 的并发效率。就 ConcurrentHashMap 容器本身的实现来说,版本之间就会产生较大的差异,典型的就是 JDK1.7 和 JDK1.8 这两个版本,可以说是发生了翻天覆地的变化,在本文中也会介绍这两个版本的 ConcurrentHashMap 实现,主要的重点放在 JDK 1.8 版本上,我个人觉得 JDK 1.7 已经成为了过去式,没必要深入研究。

ConcurrentHashMap 在 JDK 1.7 中的实现

在 JDK 1.7 版本及之前的版本中,ConcurrentHashMap 为了解决 HashTable 会锁住整个 hash 表的问题,提出了分段锁的解决方案,分段锁就是将一个大的 hash 表分解成若干份小的 hash 表,需要加锁时就针对小的 hash 表进行加锁,从而来提升 hash 表的性能。JDK1.7 中的 ConcurrentHashMap 引入了 Segment 对象,将整个 hash 表分解成一个一个的 Segment 对象,每个 Segment 对象呢可以看作是一个细粒度的 HashMap。

Segment 对象继承了 ReentrantLock 类,因为 Segment 对象它就变成了一把锁,这样就可以保证数据的安全。 在 Segment 对象中通过 HashEntry 数组来维护其内部的 hash 表。每个 HashEntry 就代表了 map 中的一个 K-V,如果发生 hash 冲突时,在该位置就会形成链表。

JDK1.7 中,ConcurrentHashMap 的整体结构可以描述为下图的样子:

ConcurrentHashMap 1.7 存储结构

我们对 ConcurrentHashMap 最关心的地方莫过于如何解决 HashMap put 时候扩容引起的不安全问题?一起来看看 JDK1.7 中 ConcurrentHashMap 是如何解决这个问题的,我们先从 put 方法开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 二次哈希,以保证数据的分散性,避免哈希冲突
int hash = hash(key.hashCode());
int j = (hash >>> segmentShift) & segmentMask;
// Unsafe 调用方式,直接获取相应的 Segment
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}

在 put 方法中,首先是通过二次哈希减小哈希冲突的可能行,根据 hash 值以 Unsafe 调用方式,直接获取相应的 Segment,最终将数据添加到容器中是由 segment对象的 put 方法来完成。Segment对象的 put 方法源代码如下:

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
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 无论如何,确保获取锁 scanAndLockForPut会去查找是否有key相同Node
ConcurrentHashMap.HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
ConcurrentHashMap.HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
ConcurrentHashMap.HashEntry<K,V> first = entryAt(tab, index);
for (ConcurrentHashMap.HashEntry<K,V> e = first;;) {
// 更新已存在的key
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
e.value = value;
++modCount;
}
break;
}
e = e.next;
}
else {
if (node != null)
node.setNext(first);
else
node = new ConcurrentHashMap.HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 判断是否需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}

由于 Segment 对象本身就是一把锁,所以在新增数据的时候,相应的 Segment对象块是被锁住的,其他线程并不能操作这个 Segment 对象,这样就保证了数据的安全性,在扩容时也是这样的,在 JDK1.7 中的 ConcurrentHashMap扩容只是针对 Segment 对象中的 HashEntry 数组进行扩容,还是因为 Segment 对象是一把锁,所以在 rehash 的过程中,其他线程无法对 segment 的 hash 表做操作,这就解决了 HashMap 中 put 数据引起的闭环问题

关于 JDK1.7 中的 ConcurrentHashMap 就聊这么多,我们只需要直到在 JDK1.7 中 ConcurrentHashMap 采用分段锁的方式来解决 HashMap 不安全问题。

ConcurrentHashMap 在 JDK1.8 中的实现

在 JDK1.8 中 ConcurrentHashMap 又发生了翻天覆地的变化,从实现的代码量上就可以看出来,在 1.7 中不到 2000行代码,而在 1.8 中已经 6000多行代码了 。废话不多说,我们来看看有那些变化。

先从容器安全说起,在容器安全上,1.8 中的 ConcurrentHashMap 放弃了 JDK1.7 中的分段技术,而是采用了 CAS 机制 + synchronized 来保证并发安全性,但是在 ConcurrentHashMap 实现里保留了 Segment 定义,这仅仅是为了保证序列化时的兼容性而已,并没有任何结构上的用处。 这里插播个 CAS 机制的知识点:

CAS 机制

CAS 典型的应用莫过于 AtomicInteger 了,CAS 属于原子操作的一种,能够保证一次读写操作是原子的。CAS 通过将内存中的值与期望值进行比较,只有在两者相等时才会对内存中的值进行修改,CAS 是在保证性能的同时提供并发场景下的线程安全性。在 Java 中 CAS 实现位于 sun.misc.Unsafe 类中,该类中定义了大量的 native 方法,CAS 的实现有以下几个方法:

1
2
3
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x);

我们只能看到定义,并不能看到具体的实现,具体的实现依赖于操作系统,我们就不去管这些了,简单了解方法里面的参数是啥意思就行了:

  • o :目标操作对象
  • offset :目标操作数内存偏移地址
  • expected :期望值
  • x :更新值

CAS 机制虽然无需加锁、安全且高效,但也存在一些缺点,概括如下:

  • 循环检查的时间可能较长,不过可以限制循环检查的次数
  • 只能对一个共享变量执行原子操作
  • 存在 ABA 问题(ABA 问题是指在 CAS 两次检查操作期间,目标变量的值由 A 变为 B,又变回 A,但是 CAS 看不到这中间的变换,对它来说目标变量的值并没有发生变化,一直是 A,所以 CAS 操作会继续更新目标变量的值。)

在存储结构上,JDK1.8 中 ConcurrentHashMap 放弃了 HashEntry 结构而是采用了跟 HashMap 结构非常相似,采用 Node 数组加链表(链表长度大于8时转成红黑树)的形式,Node 节点设计如下:

1
2
3
4
5
6
7
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
...省略...
}

跟 HashMap 一样 Key 字段被 final 修饰,说明在生命周期内,key 是不可变的, val 字段被 volatile 修饰了,这就保证了 val 字段的可见性。

JDK1.8 中的 ConcurrentHashMap 结构如下图所示:

JDK1.8 ConcurrentHashMap 结构图

在这里我提一下 ConcurrentHashMap 默认构造函数,我觉得这个地方比较有意思,ConcurrentHashMap 的默认构造函数如下:

1
2
public ConcurrentHashMap() {
}

发现没这个构造函数啥事没干,为啥要这样设计?这样做的好处是实现了懒加载(lazy-load 形式),有效避免了初始化的开销,这也是 JDK1.7 中ConcurrentHashMap 被很多人抱怨的地方。

结构上的变化就聊上面的两点,跟上面一样,我们还是来看看我们关心的问题,如何解决 HashMap 扩容时不安全的问题,带着这个问题来阅读 ConcurrentHashMap 的源代码,关于 ConcurrentHashMap 的源代码,在本文中主要聊新增(putVal )和扩容(transfer )这两个方法,其他方法就不在一一介绍了。

putVal 方法

ConcurrentHashMap 新增元素并不是直接调用 putVal 方法,而是使用 put 方法

1
2
3
public V put(K key, V value) {
return putVal(key, value, false);
}

但是 put 方法调用了 putVal 方法,换一句话来说就是 putVal 是具体的新增方法,是 put 方法的具体实现,在 putVal 方法源码加上了注释,具体代码如下:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 如果 key 为空,直接返回
if (key == null || value == null) throw new NullPointerException();
// 两次 hash ,减少碰撞次数
int hash = spread(key.hashCode());
// 记录链表节点得个数
int binCount = 0;
// 无条件得循环遍历整个 node 数组,直到成功
for (ConcurrentHashMap.Node<K,V>[] tab = table;;) {
ConcurrentHashMap.Node<K,V> f; int n, i, fh;
// lazy-load 懒加载的方式,如果当前 tab 容器为空,则初始化 tab 容器
if (tab == null || (n = tab.length) == 0)
tab = initTable();

// 通过Unsafe.getObjectVolatile()的方式获取数组对应index上的元素,如果元素为空,则直接无所插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//// 利用CAS去进行无锁线程安全操作
if (casTabAt(tab, i, null,
new ConcurrentHashMap.Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果 fh == -1 ,说明正在扩容,那么该线程也去帮扩容
else if ((fh = f.hash) == MOVED)
// 协作扩容操作
tab = helpTransfer(tab, f);
else {
// 如果上面都不满足,说明存在 hash 冲突,则使用 synchronized 加锁。锁住链表或者红黑树的头结点,来保证操作安全
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {

if (fh >= 0) {// 表示该节点是链表
binCount = 1;
// 遍历该节点上的链表
for (ConcurrentHashMap.Node<K,V> e = f;; ++binCount) {
K ek;
//这里涉及到相同的key进行put就会覆盖原先的value
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
ConcurrentHashMap.Node<K,V> pred = e;
if ((e = e.next) == null) {//插入链表尾部
pred.next = new ConcurrentHashMap.Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof ConcurrentHashMap.TreeBin) {// 该节点是红黑树节点
ConcurrentHashMap.Node<K,V> p;
binCount = 2;
if ((p = ((ConcurrentHashMap.TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 插入完之后,判断链表长度是否大于8,大于8就需要转换为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果存在相同的key ,返回原来的值
if (oldVal != null)
return oldVal;
break;
}
}
}
//统计 size,并且检测是否需要扩容
addCount(1L, binCount);
return null;
}

在源码中有比较详细的注释,如果你想了解详细的实现,可以逐行读源码,在这里我们来对 putVal 方法做一个总结,putVal 方法主要做了以下几件事:

  • 第一步、在 ConcurrentHashMap 中不允许 key val 字段为空,所以第一步先校验key value 值,key、val 两个字段都不能是 null 才继续往下走,否则直接返回一个 NullPointerException 错误,这点跟 HashMap 有区别,HashMap 是可以允许为空的。
  • 第二步、判断容器是否初始化,如果容器没有初始化,则调用 initTable 方法初始化,initTable 方法如下:
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
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 负数表示正在初始化或扩容,等待
if ((sc = sizeCtl) < 0)
// 自旋等待
Thread.yield(); // lost initialization race; just spin
// 执行 CAS 操作,期望将 sizeCtl 设置为 -1,-1 是正在初始化的标识
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// CAS 抢到了锁
try {
// 对 table 进行初始化,初始化长度为指定值,或者默认值 16
if ((tab = table) == null || tab.length == 0) {
// sc 在初始化的时候用户可能会自定义,如果没有自定义,则是默认的
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 创建数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 指定下次扩容的大小,相当于 0.75 × n
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}

Table 本质上就是一个 Node 数组,其初始化过程也就是对 Node 数组的初始化过程,方法中使用了 CAS 策略执行初始化操作。初始化流程为:

1、判断 sizeCtl 值是否小于 0,如果小于 0 则表示 ConcurrentHashMap 正在执行初始化操作,所以需要先等待一会,如果其它线程初始化失败还可以顶替上去
2、如果 sizeCtl 值大于等于 0,则基于 CAS 策略抢占标记 sizeCtl 为 -1,表示 ConcurrentHashMap 正在执行初始化,然后构造 table,并更新 sizeCtl 的值

  • 第三步、根据双哈希之后的 hash 值找到数组对应的下标位置,如果该位置未存放节点,也就是说不存在 hash 冲突,则使用 CAS 无锁的方式将数据添加到容器中,并且结束循环。
  • 第四步、如果并未满足第三步,则会判断容器是否正在被其他线程进行扩容操作,如果正在被其他线程扩容,则放弃添加操作,加入到扩容大军中(ConcurrentHashMap 扩容操作采用的是多线程的方式,后面我们会讲到),扩容时并未跳出死循环,这一点就保证了容器在扩容时并不会有其他线程进行数据添加操作,这也保证了容器的安全性
  • 第五步、如果 hash 冲突,则进行链表操作或者红黑树操作(如果链表树超过8,则修改链表为红黑树),在进行链表或者红黑树操作时,会使用 synchronized 锁把头节点被锁住了,保证了同时只有一个线程修改链表,防止出现链表成环
  • 第六步、进行 addCount(1L, binCount) 操作,该操作会更新 size 大小,判断是否需要扩容,addCount 方法源码如下:
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
// X传入的是1,check 传入的是 putVal 方法里的 binCount,没有hash冲突的话为0,冲突就会大于1
private final void addCount(long x, int check) {
ConcurrentHashMap.CounterCell[] as; long b, s;
// 统计ConcurrentHashMap里面节点个数
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
ConcurrentHashMap.CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
// check就是binCount,binCount 最小都为0,所以这个条件一定会为true
if (check >= 0) {
ConcurrentHashMap.Node<K,V>[] tab, nt; int n, sc;
// 这儿是自旋,需同时满足下面的条件
// 1. 第一个条件是map.size 大于 sizeCtl,也就是说需要扩容
// 2. 第二个条件是`table`不为null
// 3. 第三个条件是`table`的长度不能超过最大容量
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
// 该判断表示已经有线程在进行扩容操作了
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 如果可以帮助扩容,那么将 sc 加 1. 表示多了一个线程在帮助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 如果不在扩容,将 sc 更新:标识符左移 16 位 然后 + 2. 也就是变成一个负数。高 16 位是标识符,低 16 位初始是 2
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}

addCount 方法做了两个工作:
1、对 map 的 size 加一
2、检查是否需要扩容,或者是否正在扩容。如果需要扩容,就调用扩容方法,如果正在扩容,就帮助其扩容。

扩容 transfer 方法

扩容 transfer 方法是一个非常牛逼的方法,在看具体的 transfer 源码之前,我们先来了解一下什么时候会触发扩容操作,不出意外的话,以下两种情况下可能触发扩容操作

  • 调用 put 方法新增元素之后,会调用 addCount 方法来更新 size 大小,并检查是否需要进行扩容,当数组元素个数达到阈值时,会触发transfer方法
  • 触发了 tryPresize 操作, tryPresize 操作会触发扩容操作,有两种情况会触发 tryPresize 操作:
    • 第一种情况:当某节点的链表元素个数达到阈值 8 时,这时候需要将链表转成红黑树,在结构转换之前会,会先判断数组长度 n 是否小于阈值MIN_TREEIFY_CAPACITY,默认是64,如果小于则会调用tryPresize方法把数组长度扩大到原来的两倍,并触发transfer方法,重新调整节点的位置。
    • 第二种情况:在 putAll 操作时会先触发 tryPresize 操作。

tryPresize 方法源码如下:

tryPresize 方法源码

好了,知道什么时候会触发扩容后,我们来看看 扩容 transfer 方法的源码,这也是一块硬骨头,非常难啃,希望我可以尽量的把它讲清楚,transfer 方法源码如下:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
private final void transfer(ConcurrentHashMap.Node<K,V>[] tab, ConcurrentHashMap.Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 多线程扩容,每核处理的量小于16,则强制赋值16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// nextTab 为空,先实例化一个新的数组
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
// 新数组的大小是原来的两倍
ConcurrentHashMap.Node<K,V>[] nt = (ConcurrentHashMap.Node<K,V>[])new ConcurrentHashMap.Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
// 更新成员变量
nextTable = nextTab;
// 更新转移下标,就是 老的 tab 的 length
transferIndex = n;
}
// bound :该线程此次可以处理的区间的最小下标,超过这个下标,就需要重新领取区间或者结束扩容
// advance: 该参数
int nextn = nextTab.length;
// 创建一个 fwd 节点,用于占位。当别的线程发现这个槽位中是 fwd 类型的节点,则跳过这个节点。
ConcurrentHashMap.ForwardingNode<K,V> fwd = new ConcurrentHashMap.ForwardingNode<K,V>(nextTab);
// advance 变量指的是是否继续递减转移下一个桶,如果为 true,表示可以继续向后推进,反之,说明还没有处理好当前桶,不能推进
boolean advance = true;
// 完成状态,如果是 true,表示扩容结束
boolean finishing = false; // to ensure sweep before committing nextTab
// 死循环,i 表示下标,bound 表示当前线程可以处理的当前桶区间最小下标
for (int i = 0, bound = 0;;) {
ConcurrentHashMap.Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {
// 这儿多判断一次,是否为了防止可能出现的remove()操作
if (tabAt(tab, i) == f) {
// 旧链表上该节点的数据,会被分成低位和高位,低位就是在新链表上的位置跟旧链表上一样,
// 高位就是在新链表的位置是旧链表位置加上旧链表的长度
ConcurrentHashMap.Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
ConcurrentHashMap.Node<K,V> lastRun = f;
for (ConcurrentHashMap.Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (ConcurrentHashMap.Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
// 该节点哈希值与旧链表长度与运算,结果为0,则在低位节点上,反之,在高位节点上
if ((ph & n) == 0)
ln = new ConcurrentHashMap.Node<K,V>(ph, pk, pv, ln);
else
hn = new ConcurrentHashMap.Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
// 在nextTable i + n 位置处插上链表
setTabAt(nextTab, i + n, hn);
// 在table i 位置处插上ForwardingNode 表示该节点已经处理过了
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof ConcurrentHashMap.TreeBin) {
// 如果是TreeBin,则按照红黑树进行处理,处理逻辑与上面一致
// 红黑树的逻辑跟节点一模一样,最后也会分高位和低位
ConcurrentHashMap.TreeBin<K,V> t = (ConcurrentHashMap.TreeBin<K,V>)f;
ConcurrentHashMap.TreeNode<K,V> lo = null, loTail = null;
ConcurrentHashMap.TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (ConcurrentHashMap.Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
ConcurrentHashMap.TreeNode<K,V> p = new ConcurrentHashMap.TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果树的节点数小于等于 6,那么转成链表,反之,创建一个新的树
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new ConcurrentHashMap.TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new ConcurrentHashMap.TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}

想知道具体的实现细节,请逐行读源码,如果遇到不懂得,欢迎留言交流,跟 putVal 方法一样,我们同样来对 transfer 方法进行总结,transfer 大致做了以下几件事件:

  • 第一步:计算出每个线程每次可以处理的个数,根据 Map 的长度,计算出每个线程(CPU)需要处理的桶(table数组的个数),默认每个线程每次处理 16 个桶,如果小于 16 个,则强制变成 16 个桶。
  • 第二步:对 nextTab 初始化,如果传入的新 table nextTab 为空,则对 nextTab 初始化,默认是原 table 的两倍
  • 第三步:引入 ForwardingNode、advance、finishing 变量来辅助扩容,ForwardingNode 表示该节点已经处理过,不需要在处理,advance 表示该线程是否可以下移到下一个桶(true:表示可以下移),finishing 表示是否结束扩容(true:结束扩容,false:未结束扩容) ,具体的逻辑就不说了
  • 第四步:跳过一些其他细节,直接到数据迁移这一块,在数据转移的过程中会加 synchronized 锁,锁住头节点,同步化操作,防止 putVal 的时候向链表插入数据
  • 第五步:进行数据迁移,如果这个桶上的节点是链表或者红黑树,则会将节点数据分为低位和高位,计算的规则是通过该节点的 hash 值跟为扩容之前的 table 容器长度进行位运算(&),如果结果为 0 ,则将数据放在新表的低位(当前 table 中为 第 i 个位置,在新表中还是第 i 个位置),结果不为 0 ,则放在新表的高位(当前 table 中为第 i 个位置,在新表中的位置为 i + 当前 table 容器的长度)
  • 第六步:如果桶挂载的是红黑树,不仅需要分离出低位节点和高位节点,还需要判断低位和高位节点在新表以链表还是红黑树的形式存放。

本文整理自

ConcurrentHashMap

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


序列化与反序列化是开发过程中不可或缺的一步,简单来说,序列化是将对象转换成字节流的过程,而反序列化的是将字节流恢复成对象的过程。两者的关系如下:

img

序列化与反序列化是一个标准(具体参考XDR:外部数据表示标准 RFC 1014),它是编程语言的一种共性,只是有些编程语言是内置的(如Java,PHP等),有些语言是通过第三方库来实现的(如C/C++)。

使用场景

  • 对象的持久化(将对象内容保存到数据库或文件中)
  • 远程数据传输(将对象发送给其他计算机系统)

为什么需要序列化与反序列化?

序列化与序列化主要解决的是数据的一致性问题。简单来说,就是输入数据与输出数据是一样的。

对于数据的本地持久化,只需要将数据转换为字符串进行保存即可是实现,但对于远程的数据传输,由于操作系统,硬件等差异,会出现内存大小端,内存对齐等问题,导致接收端无法正确解析数据,为了解决这种问题,Sun Microsystems在20世纪80年代提出了XDR规范,于1995年正式成为IETF标准。

Java中的序列化与反序列化

Java语言内置了序列化和反序列化,通过Serializable接口实现。

1
2
3
4
5
6
public class Account implements Serializable {

private int age;
private long birthday;
private String name;
}

序列化兼容性

序列化的兼容性指的是对象的结构变化(如增删字段,修改字段,字段修饰符的改变等)对序列化的影响。为了能够识别对象结构的变化,Serializable使用serialVersionUID字段来标识对象的结构。默认情况下,它会根据对象的数据结构自动生成,结构发生变化后,它的值也会跟随变化。虚拟机在反序列化的时候会检查serialVersionUID的值,如果字节码中的serialVersionUID和要被转换的类型的serialVersionUID不一致,就无法进行正常的反序列化。

示例:将Account对象保存到文件中,然后在Account类中添加address字段,再从文件中读取之前保存的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 将Account对象保存到文件中
FileOutputStream fos = new FileOutputStream(file);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(account);
oos.flush();

// 修改Account对象的结构
public class Account implements Serializable {

private int age;
private long birthday;
private String name;
private String address;

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

// 读取Account的内容
FileInputStream fis = new FileInputStream(file);
ObjectInputStream ois = new ObjectInputStream(fis);
Account account2 = (Account)ois.readObject();

由于在保存Account对象后修改了Account的结构,会导致serialVersionUID的值发生变化,在读文件(反序列化)的时候就会出错。所以为了更好的兼容性,在序列化的时候,最好将serialVersionUID的值设置为固定的

1
2
3
4
5
6
7
8
public class Account implements Serializable {

private static final long serialVersionUID = 1L;

private int age;
private long birthday;
private String name;
}

序列化的存储规则

Java中的序列化在将对象持久化(序列化)的时候,为了节省磁盘空间,对于相同的对象会进行优化。当多次保存相同的对象时,其实保存的只是第一个对象的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 将account对象保存两次,第二次保存时修改其用户名
Account account = new Account("Freeman");
FileOutputStream fos = new FileOutputStream(file);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(account);
System.out.println("fileSize=" +file.length());
account.setUserName("Tom");
oos.writeObject(account);
System.out.println("fileSize=" +file.length());

// 读取两次保存的account对象
FileInputStream fis = new FileInputStream(file);
ObjectInputStream ois = new ObjectInputStream(fis);
Account account2 = (Account)ois.readObject();
Account account3 = (Account)ois.readObject();
System.out.println("account2.name=" + account2.getUserName() + "\n account3.name=" + account3.getUserName() + "\naccount2==account3 -> " + account2.equals(account3));

输出结果:

1
2
3
account2.name=Freeman  
account3.name=Freeman
account2==account3 -> true

所以在对同一个对象进行多次序列化的时候,最好通过clone一个新的对象再进行序列化。

序列化对单例的影响

反序列化的时候,JVM会根据序列化生成的内容构造新的对象,对于实现了Serializable的单例类来说,这相当于开放了构造方法。为了保证单例类实例的唯一性,我们需要重写resolveObject方法。

1
2
3
4
5
6
7
8
/**
* 在反序列化的时候被调用
* @return 返回根据字节码创建的新对象
* @throws ObjectStreamException
*/
private Object readResolve()throws ObjectStreamException {
return instance;
}

控制序列化过程

虽然直接使用Serializable很方便,但有时我们并不想序列化所有的字段,如标识选中状态的isSelected字段,涉及安全问题的password字段等。此时可通过通过以下方法实现:

  1. 给不想序列化的字段添加static或transient修饰词:

Java中的序列化保存的只是对象的成员变量,既不包括static成员(static成员属于类),也不包括成员方法。同时Java为了让序列化更灵活,提供了transient关键字,用来关闭字段的序列化。

1
2
3
4
5
6
7
8
public class Account implements Serializable {

private static final long serialVersionUID = 1L;

private String userName;
private static String idcard;
private transient String password;
}
  1. 直接使用Externalizable接口控制序列化过程:

Externalizable也是Java提供的序列化接口,与Serializable不同的是,默认情况下,它不会序列化任何成员变量,所有的序列化,反序列化工作都需要手动完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Account implements Externalizable {

private static final long serialVersionUID = 1L;

private String userName;
private String idcard;
private String password;

@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeObject(userName);
}

@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
userName = (String) in.readObject();
}
}
  1. 自己实现序列化/反序列化过程

    public class Account implements Serializable {

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    private static final long serialVersionUID = 1L;

    private String userName;
    private transient String idcard;
    private String password;

    private void writeObject(ObjectOutputStream oos)throws IOException {
    // 调用默认的序列化方法,序列化非transient/static字段
    oos.defaultWriteObject();
    oos.writeObject(idcard);
    }

    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
    // 调用默认的反序列化方法,发序列化非transient/static字段
    ois.defaultReadObject();
    idcard = (String)ois.readObject();
    }

    }

关于Java序列化算法的详细介绍可参考:Java序列化算法透析

Java序列化注意事项

  1. 通过Serializable序列化的对象,在反序列化的时候,直接根据字节码构造对象,并不会调用对象的构造方法;
  2. 通过Serializable序列化子类时,如果父类没有实现Serializable接口,那么父类需要提供默认的构造方法,否则在反序列化的时候抛出java.io.NotSerializableException异常;
  3. 通过Externalizale实现序列化时,反序列化的时候需要调用对象的默认构造方法;
  4. 由于Externalizale默认情况下不会对任何成员变量进行序列化,所以transient关键字只能在Serializable序列化方式中使用;

数据交换协议

序列化与反序列化为数据交换提供了可能,但是因为传递的是字节码,可读性差。在应用层开发过程中不易调试,为了解决这种问题,最直接的想法就是将对象的内容转换为字符串的形式进行传递。具体的传输格式可自行定义,但自定义格式有一个很大的问题——兼容性,如果引入其他系统的模块,就需要对数据格式进行转换,维护其他的系统时,还要先了解一下它的序列化方式。为了统一数据传输的格式,出现了几种数据交换协议,如:JSON, Protobuf,XML。这些数据交换协议可视为是应用层面的序列化/反序列化。

JSON

JSON(JavaScript Object Notation)是一种轻量级,完全独立于语言的数据交换格式。目前被广泛应用在前后端的数据交互中。

语法

JSON中的元素都是键值对——key:value形式,键值对之间以”:”分隔,每个键需用双引号引起来,值的类型为String时也需要双引号。其中value的类型包括:对象,数组,值,每种类型具有不同的语法表示。

对象

对象是一个无序的键值对集合。以”{“开始,以”}”结束, 每个成员以”,”分隔。例如:

1
2
3
4
"value" : {
"name": "Freeman",
"gender": 1
}
数组

数组是一个有序的集合,以”[“开始,以”]”结束,成员之间以”,”分隔。例如:

1
2
3
4
5
6
7
8
9
10
"value" : [
{
"name": "zhangsan",
"gender": 1
},
{
"name": "lisi",
"gender": 2
}
]

值类型表示JSON中的基本类型,包括String,Number(byte, short, int, long, float, double), boolean。

1
2
3
4
"name": "Freeman"
"gender": 1
"registered": false
"article": null

==注意==:对象,数组,值这三种元素可互相嵌套!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"code": 1,
"msg": "success",
"data": [
{
"name": "zhangsan",
"gender": 1
},
{
"name": "lisi",
"gender": 2
}
]
}
复制代码

对于JSON,目前流行的第三方库有Gson, fastjson:关于Gson的详细介绍,参考Gson使用教程

Protobuf

Protobuf是Google实现的一种与语言无关,与平台无关,可扩展的序列化方式,比XML更小,更快,使用更简单。

Protobuf具有很高的效率,并且几乎为主流的开发语言都提供了支持,具体参考Protobuf开发文档

在Android中使用Protobuf,需要protobuf-gradle-plugin插件,具体使用查看其项目说明。

XML

XML(Extensible Markup Language)可扩展标记语言,通过标签描述数据。示例如下:

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<person>
<name>Freeman</name>
<gender>1</gender>
</person>

使用这种方式传输数据时,只需要将对象转换成这种标签形式,在接收到数据后,将其转换成相应的对象。

关于JAVA开发中对XML的解析可参考四种生成和解析XML文档的方法详解

数据交换协议如何选择

从性能,数据大小,可读性三方面进行比较,结果如下:

协议 性能 数据大小 可读性
JSON
Protobuf
XML

对于数据量不是很大,实时性不是特别高的交互,JSON完全可以满足要求,毕竟它的可读性高,出现问题容易定位(注:它是目前前端,app和后端交换数据使用的主流协议)。而对于实时性要求很高,或数据量大的场景,可使用Protobuf协议。具体数据交换协议的比较可参考github.com/eishay/jvm-…


本文整理自

序列化与反序列化

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


构造方法

1
2
3
4
5
6
 * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
* default initial capacity (16) and load factor (0.75).
*/
public HashSet() {
map = new HashMap<>();
}

很明显,HashSet底层是hashmap存储的。借大神的话

HashSet 就是HashMap的马甲 —–someone

很形象哈。

add()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Dummy value to associate with an Object in the backing Map
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
/**
* Adds the specified element to this set if it is not already present.
* More formally, adds the specified element <tt>e</tt> to this set if
* this set contains no element <tt>e2</tt> such that
* <tt>(e==null&nbsp;?&nbsp;e2==null&nbsp;:&nbsp;e.equals(e2))</tt>.
* If this set already contains the element, the call leaves the set
* unchanged and returns <tt>false</tt>.
*
* @param e element to be added to this set
* @return <tt>true</tt> if this set did not already contain the specified
* element
*/
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}

add方法的参数(要存储的value)作为HashMap的key,PRESENT(Object PRESENT = new Object();)作为固定value。

key

HashMap中的put方法

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
 public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

这里边有两个看点:

  • HashMap中key存储是hash后的值,对于String类型的相同值的hash值是一致的(其他接触类型类似,自定义对象类型需要重写hashcode方法与equel方法)。换句话说相同的值在hashMap中的存储位置是一样的。
  • 基于上一点来看看怎么存储重复值的。如下代码对于hashMap中已经存在的key,key不变,新value覆盖就value。对于HashSet而言新旧value都是PRESENT对象,所以set在存储的时候就不会重复。
1
2
3
4
5
6
7
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}

所以hashset中存储的值输出的顺序和存储的先后顺序不一致,而是按照值的hash顺序输出。

总结:

通过分析HashSet的实现原理,可以肯定的是它的去重效率是很高的,前提是去重对象需要有hashcode、equel方法的实现。除此外HashMap所拥有的大多数特性都适用于HashSet。


本文整理自

HashSet集合是怎么实现不重复的

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


前言

最近正在啃《高性能MySQL》这本书, 当看到事务相关知识时,决定对该知识点稍微深入一下, 《高性能MySQL》中在介绍事务相关知识点时, 显然不是特别深入, 很多比较底层的知识点并没有太多的深入, 当然此处并不是要对本书做什么评判,言归正传, 这里主要先说一下本人在啃相关知识点时的曲折之路:

  1. 首先是事务相关ACID特性, 之前已经有相关笔记进行过介绍, 这里不再重复;
  2. 接下来是高并发事务相关的问题, 像是 脏读, 不可重复读, 幻读, 更新丢失等问题之前也有相关笔记;
  3. 再下来就是MySQL应对高并发事务是如何给出解决方案的(其中包含各个隔离级别的简介);
  4. 然后就是各个隔离级别的具体介绍及与锁的关系, 也就是在这部分知识点, 发现了之前并没有过多关心的知识点 MVCC多版本并发控制, 然后一发不可收拾了…

入题

下面先引用一些前辈们比较优秀的文章:

阿里数据库内核’2017/12’月报中对MVCC的解释是:
多版本控制: 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,与Postgres在数据行上实现多版本不同,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。

<高性能MySQL>中对MVCC的部分介绍

  • MySQL的大多数事务型存储引擎实现的其实都不是简单的行级锁。基于提升并发性能的考虑, 它们一般都同时实现了多版本并发控制(MVCC)。不仅是MySQL, 包括Oracle,PostgreSQL等其他数据库系统也都实现了MVCC, 但各自的实现机制不尽相同, 因为MVCC没有一个统一的实现标准。
  • 可以认为MVCC是行级锁的一个变种, 但是它在很多情况下避免了加锁操作, 因此开销更低。虽然实现机制有所不同, 但大都实现了非阻塞的读操作,写操作也只锁定必要的行。
  • MVCC的实现方式有多种, 典型的有乐观(optimistic)并发控制悲观(pessimistic)并发控制
  • MVCC只在 READ COMMITTEDREPEATABLE READ 两个隔离级别下工作。其他两个隔离级别和MVCC不兼容, 因为 READ UNCOMMITTED 总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。

从书中可以了解到:

  • MVCC是被Mysql中 事务型存储引擎InnoDB 所支持的;
  • 应对高并发事务, MVCC比单纯的加锁更高效;
  • MVCC只在 READ COMMITTEDREPEATABLE READ 两个隔离级别下工作;
  • MVCC可以使用 乐观(optimistic)锁悲观(pessimistic)锁来实现;
  • 各数据库中MVCC实现并不统一
  • 但是书中提到 “InnoDB的MVCC是通过在每行记录后面保存两个隐藏的列来实现的”(网上也有很多此类观点), 但其实并不准确, 可以参考MySQL官方文档, 可以看到, InnoDB存储引擎在数据库每行数据的后面添加了三个字段, 和MVCC有关系的有两个(数据行的版本号 (DB_TRX_ID)和删除版本号 (DB_ROLL_PT))

相关概念

1.read view, 快照snapshot

淘宝数据库内核月报/2017/10/01/
此文虽然是以PostgreSQL进行的说明, 但并不影响理解, 在”事务快照的实现”该部分有细节需要注意:
事务快照是用来存储数据库的事务运行情况。一个事务快照的创建过程可以概括为:
查看当前所有的未提交并活跃的事务,存储在数组中
选取未提交并活跃的事务中最小的XID,记录在快照的xmin中
选取所有已提交事务中最大的XID,加1后记录在xmax中

注意: 上文中在PostgreSQL中snapshot的概念, 对应MySQL中, 其实就是你在网上看到的read view,快照这些概念;
比如何登成就有关于Read view的介绍;
此文 却仍是使用快照来介绍;

2.read view 主要是用来做可见性判断的, 比较普遍的解释便是”本事务不可见的当前其他活跃事务”, 但正是该解释, 可能会造成一节理解上的误区, 所以此处提供两个参考, 供给大家避开理解误区:

1
2
read view中的`高水位low_limit_id`可以参考 https://github.com/zhangyachen/zhangyachen.github.io/issues/68, https://www.zhihu.com/question/66320138
其实上面第1点中加粗部分也是相关高水位的介绍( 注意进行了+1 )

3.另外, 对于read view快照的生成时机, 也非常关键, 正是因为生成时机的不同, 造成了RC,RR两种隔离级别的不同可见性;

  • 在innodbrepeatable read级别中, 事务在begin/start transaction之后的第一条select读操作后, 会创建一个快照(read view), 将当前系统中活跃的其他事务记录记录起来;
  • 在innodb read committed级别中, 事务中每条select语句都会创建一个快照(read view);
  • 参考
1
2
3
4
With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed.
使用REPEATABLE READ隔离级别,快照是基于执行第一个读操作的时间。
With READ COMMITTED isolation level, the snapshot is reset to the time of each consistent read operation.
使用READ COMMITTED隔离级别,快照被重置为每个一致的读取操作的时间。

4.undo-log

  • Undo log是InnoDB MVCC事务特性的重要组成部分。当我们对记录做了变更操作时就会产生undo记录,Undo记录默认被记录到系统表空间(ibdata)中,但从5.6开始,也可以使用独立的Undo 表空间。
  • Undo记录中存储的是老版本数据,当一个旧的事务需要读取数据时,为了能读取到老版本的数据,需要顺着undo链找到满足其可见性的记录。当版本链很长时,通常可以认为这是个比较耗时的操作(例如bug#69812)。
  • 大多数对数据的变更操作包括INSERT/DELETE/UPDATE,其中INSERT操作在事务提交前只对当前事务可见,因此产生的Undo日志可以在事务提交后直接删除(谁会对刚插入的数据有可见性需求呢!!),而对于UPDATE/DELETE则需要维护多版本信息,在InnoDB里,UPDATE和DELETE操作产生的Undo日志被归成一类,即update_undo
  • 另外, 在回滚段中的undo logs分为: insert undo logupdate undo log
    • insert undo log : 事务对insert新记录时产生的undolog, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
    • update undo log : 事务对记录进行delete和update操作时产生的undo log, 不仅在事务回滚时需要, 一致性读也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除。

5.InnoDB存储引擎在数据库每行数据的后面添加了三个字段

  • 6字节的事务ID(DB_TRX_ID)字段: 用来标识最近一次对本行记录做修改(insert|update)的事务的标识符, 即最后一次修改(insert|update)本行记录的事务id。
    至于delete操作,在innodb看来也不过是一次update操作,更新行中的一个特殊位将行表示为deleted, 并非真正删除
  • 7字节的回滚指针(DB_ROLL_PTR)字段: 指写入回滚段(rollback segment)的 undo log record (撤销日志记录记录)。
    如果一行记录被更新, 则 undo log record 包含 ‘重建该行记录被更新之前内容’ 所必须的信息。
  • 6字节的DB_ROW_ID字段: 包含一个随着新行插入而单调递增的行ID, 当由innodb自动产生聚集索引时,聚集索引会包括这个行ID的值,否则这个行ID不会出现在任何索引中。
    结合聚簇索引的相关知识点, 我的理解是, 如果我们的表中没有主键或合适的唯一索引, 也就是无法生成聚簇索引的时候, InnoDB会帮我们自动生成聚集索引, 但聚簇索引会使用DB_ROW_ID的值来作为主键; 如果我们有自己的主键或者合适的唯一索引, 那么聚簇索引中也就不会包含 DB_ROW_ID 了 。
    关于聚簇索引, 《高性能MySQL》中的篇幅对我来说已经够用了, 稍后会整理一下以前的学习笔记, 然后更新上来。

6.可见性比较算法(这里每个比较算法后面的描述是建立在rr级别下,rc级别也是使用该比较算法,此处未做描述)
设要读取的行的最后提交事务id(即当前数据行的稳定事务id)为 trx_id_current
当前新开事务id为 new_id
当前新开事务创建的快照read view 中最早的事务id为up_limit_id, 最迟的事务id为low_limit_id(注意这个low_limit_id=未开启的事务id=当前最大事务id+1)
比较:

  • 1.trx_id_current < up_limit_id, 这种情况比较好理解, 表示, 新事务在读取该行记录时, 该行记录的稳定事务ID是小于, 系统当前所有活跃的事务, 所以当前行稳定数据对新事务可见, 跳到步骤5.
  • 2.trx_id_current >= trx_id_last, 这种情况也比较好理解, 表示, 该行记录的稳定事务id是在本次新事务创建之后才开启的, 但是却在本次新事务执行第二个select前就commit了,所以该行记录的当前值不可见, 跳到步骤4。
  • 3.trx_id_current <= trx_id_current <= trx_id_last, 表示: 该行记录所在事务在本次新事务创建的时候处于活动状态,从up_limit_id到low_limit_id进行遍历,如果trx_id_current等于他们之中的某个事务id的话,那么不可见, 调到步骤4,否则表示可见。
  • 4.从该行记录的 DB_ROLL_PTR 指针所指向的回滚段中取出最新的undo-log的版本号, 将它赋值该 trx_id_current,然后跳到步骤1重新开始判断。
  • 5.将该可见行的值返回。

案例分析

  1. 下面是一个非常简版的演示事务对某行记录的更新过程, 当然, InnoDB引擎在内部要做的工作非常多:
    clipboard.png
  2. 下面是一套比较算法的应用过程, 比较长
    比较算法

当前读和快照读

1.MySQL的InnoDB存储引擎默认事务隔离级别是RR(可重复读), 是通过 “行排他锁+MVCC” 一起实现的, 不仅可以保证可重复读, 还可以部分防止幻读, 而非完全防止;

2.为什么是部分防止幻读, 而不是完全防止?

  • 效果: 在如果事务B在事务A执行中, insert了一条数据并提交, 事务A再次查询, 虽然读取的是undo中的旧版本数据(防止了部分幻读), 但是事务A中执行update或者delete都是可以成功的!!
  • 因为在innodb中的操作可以分为当前读(current read)快照读(snapshot read):

3.快照读(snapshot read)

1
简单的select操作(当然不包括 select ... lock in share mode, select ... for update)

4.当前读(current read) 官网文档 Locking Reads

  • select … lock in share mode
  • select … for update
  • insert
  • update
  • delete

在RR级别下,快照读是通过MVVC(多版本控制)和undo log来实现的,当前读是通过加record lock(记录锁)和gap lock(间隙锁)来实现的。
innodb在快照读的情况下并没有真正的避免幻读, 但是在当前读的情况下避免了不可重复读和幻读!!!

小结

  1. 一般我们认为MVCC有下面几个特点:
    • 每行数据都存在一个版本,每次数据更新时都更新该版本
    • 修改时Copy出当前版本, 然后随意修改,各个事务之间无干扰
    • 保存时比较版本号,如果成功(commit),则覆盖原记录, 失败则放弃copy(rollback)
    • 就是每行都有版本号,保存时根据版本号决定是否成功,听起来含有乐观锁的味道, 因为这看起来正是,在提交的时候才能知道到底能否提交成功
  2. 而InnoDB实现MVCC的方式是:
    • 事务以排他锁的形式修改原始数据
    • 把修改前的数据存放于undo log,通过回滚指针与主数据关联
    • 修改成功(commit)啥都不做,失败则恢复undo log中的数据(rollback)
  3. 二者最本质的区别是: 当修改数据时是否要排他锁定,如果锁定了还算不算是MVCC?
  • Innodb的实现真算不上MVCC, 因为并没有实现核心的多版本共存, undo log 中的内容只是串行化的结果, 记录了多个事务的过程, 不属于多版本共存。但理想的MVCC是难以实现的, 当事务仅修改一行记录使用理想的MVCC模式是没有问题的, 可以通过比较版本号进行回滚, 但当事务影响到多行数据时, 理想的MVCC就无能为力了。
  • 比如, 如果事务A执行理想的MVCC, 修改Row1成功, 而修改Row2失败, 此时需要回滚Row1, 但因为Row1没有被锁定, 其数据可能又被事务B所修改, 如果此时回滚Row1的内容,则会破坏事务B的修改结果,导致事务B违反ACID。 这也正是所谓的 第一类更新丢失 的情况。
  • 也正是因为InnoDB使用的MVCC中结合了排他锁, 不是纯的MVCC, 所以第一类更新丢失是不会出现了, 一般说更新丢失都是指第二类丢失更新。

本文整理自

MySQL-InnoDB-MVCC多版本并发控制

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


1 简介

​ 什么是服务降级?当服务器压力剧增的情况下,根据实际业务情况及流量,对一些服务和页面有策略的不处理或换种简单的方式处理,从而释放服务器资源以保证核心交易正常运作或高效运作。

​ 如果还是不理解,那么可以举个栗子:假如目前有很多人想要给我付钱,但我的服务器除了正在运行支付的服务之外,还有一些其它的服务在运行,比如搜索、定时任务和详情等等。然而这些不重要的服务就占用了JVM的不少内存与CPU资源,为了能把钱都收下来(钱才是目标),我设计了一个动态开关,把这些不重要的服务直接在最外层拒掉,这样处理后的后端处理收钱的服务就有更多的资源来收钱了(收钱速度更快了),这就是一个简单的服务降级的使用场景。

2 使用场景

​ 服务降级主要用于什么场景呢?当整个微服务架构整体的负载超出了预设的上限阈值或即将到来的流量预计将会超过预设的阈值时,为了保证重要或基本的服务能正常运行,我们可以将一些 不重要不紧急 的服务或任务进行服务的 延迟使用暂停使用

3 核心设计

3.1 分布式开关

​ 根据上述需求,我们可以设置一个分布式开关,用于实现服务的降级,然后集中式管理开关配置信息即可。具体方案如下:

img

服务降级-分布式开关

3.2 自动降级

  • 超时降级 —— 主要配置好超时时间和超时重试次数和机制,并使用异步机制探测恢复情况
  • 失败次数降级 —— 主要是一些不稳定的API,当失败调用次数达到一定阀值自动降级,同样要使用异步机制探测回复情况
  • 故障降级 —— 如要调用的远程服务挂掉了(网络故障、DNS故障、HTTP服务返回错误的状态码和RPC服务抛出异常),则可以直接降级
  • 限流降级 —— 当触发了限流超额时,可以使用暂时屏蔽的方式来进行短暂的屏蔽

​ 当我们去秒杀或者抢购一些限购商品时,此时可能会因为访问量太大而导致系统崩溃,此时开发者会使用限流来进行限制访问量,当达到限流阀值,后续请求会被降级;降级后的处理方案可以是:排队页面(将用户导流到排队页面等一会重试)、无货(直接告知用户没货了)、错误页(如活动太火爆了,稍后重试)。

3.3 配置中心

​ 微服务降级的配置信息是集中式的管理,然后通过可视化界面进行友好型的操作。配置中心和应用之间需要网络通信,因此可能会因网络闪断或网络重启等因素,导致配置推送信息丢失、重启或网络恢复后不能再接受、变更不及时等等情况,因此服务降级的配置中心需要实现以下几点特性,从而尽可能的保证配置变更即使达到:

img

服务降级-配置中心

  • 启动主动拉取配置 —— 用于初始化配置(减少第一次定时拉取周期)
  • 发布订阅配置 —— 用于实现配置及时变更(可以解决90%左右的配置变更)
  • 定时拉取配置 —— 用于解决发布订阅失效或消失丢失的情况(可以解决9%左右的发布订阅失效的消息变更)
  • 离线文件缓存配置 —— 用于临时解决重启后连接不上配置中心的问题
  • 可编辑式配置文档 —— 用于直接编辑文档的方式来实现配置的定义
  • 提供Telnet命令变更配置 —— 用于解决配置中心失效而不能变更配置的常见

3.4 处理策略

​ 当触发服务降级后,新的交易再次到达时,我们该如何来处理这些请求呢?从微服务架构全局的视角来看,我们通常有以下是几种常用的降级处理方案:

  • 页面降级 —— 可视化界面禁用点击按钮、调整静态页面
  • 延迟服务 —— 如定时任务延迟处理、消息入MQ后延迟处理
  • 写降级 —— 直接禁止相关写操作的服务请求
  • 读降级 —— 直接禁止相关度的服务请求
  • 缓存降级 —— 使用缓存方式来降级部分读频繁的服务接口

​ 针对后端代码层面的降级处理策略,则我们通常使用以下几种处理措施进行降级处理:

  • 抛异常
  • 返回NULL
  • 调用Mock数据
  • 调用Fallback处理逻辑

4 高级特性

​ 我们已经为每个服务都做好了一个降级开关,也已经在线上验证通过了,感觉完全没问题了。
场景一:某一天,运营搞了一次活动,突然跑过来说,现在流量已经快涨到上限了,有没有批量降级所有不重要服务的方式?开发一脸懵逼的看着,这又不是操作DB,哪里有批量操作呀。
场景二:某一天,运营又搞事了,说我们等下要搞一个活动,让我们赶紧提前把不重要的服务都降级了,开发又是一脸懵逼,我怎么知道要降级哪些服务呀。
反思:服务降级的功能虽然是实现了,可是没有考虑实施时的体验。服务太多,不知道该降级哪些服务,单个操作降级速度太慢……

4.1 分级降级

​ 当微服务架构发生不同程度的情况时,我们可以根据服务的对比而进行选择式舍弃(即丢车保帅的原则),从而进一步保障核心的服务的正常运作。

​ 如果等线上服务即将发生故障时,才去逐个选择哪些服务该降级、哪些服务不能降级,然而线上有成百上千个服务,则肯定是来不及降级就会被拖垮。同时,在大促或秒杀等活动前才去梳理,也是会有不少的工作量,因此建议在开发期就需要架构师或核心开发人员来提前梳理好,是否能降级的初始评估值,即是否能降级的默认值。

​ 为了便于批量操作微服务架构中服务的降级,我们可以从全局的角度来建立服务重要程度的评估模型,如果有条件的话,建议可以使用 层次分析法(The analytic hierarchy process,简称AHP) 的数学建模模型(或其它模型)来进行定性和定量的评估(肯定比架构师直接拍脑袋决定是否降级好很多倍,当然难度和复杂度也会高许多,即你需要一个会数学建模人才),而层次分析法的基本思路是人对一个复杂的决策问题的思维和判断过程大体上是一样的。

​ 以下是个人给出的最终评价模型,可作为服务降级的评价参考模型进行设计:

​ 我们利用数学建模的方式或架构师直接拍脑袋的方式,结合服务能否降级的优先原则,并根据台风预警(都属于风暴预警)的等级进行参考设计,可将微服务架构的所有服务进行故障风暴等级划分为以下四种:

评估模型

  • 蓝色风暴 —— 表示需要小规模降级非核心服务
  • 黄色风暴 —— 表示需要中等规模降级非核心服务
  • 橙色风暴 —— 表示需要大规模降级非核心服务
  • 红色风暴 —— 表示必须降级所有非核心服务

设计说明

  • 故障严重程度为:蓝色<黄色<橙色<红色
  • 建议根据二八原则可以将服务划分为:80%的非核心服务+20%的核心服务

​ 以上模型只是整体微服务架构的服务降级评估模型,具体大促或秒杀活动时,建议以具体主题为中心进行建立(不同主题的活动,因其依赖的服务不同,而使用不同的进行降级更为合理)。当然模型可以使用同一个,但其数据需要有所差异。最好能建立一套模型库,然后实施时只需要输入相关服务即可输出最终降级方案,即输出本次大促或秒杀时,当发生蓝色风暴时需要降级的服务清单、当发生黄色风暴时需要降级的服务清单……

4.2 降级权值

​ 微服务架构中有服务权值的概念,主要用于负载时的权重选择,同样服务降级权值也是类似,主要用于服务降级选择时的细粒度优先级抉择。所有的服务直接使用以上简单的四级划分方式进行统一处理,显然粒度太粗,或者说出于同一级的多个服务需要降级时的 降级顺序 该如何?甚至我想要人工智能化的 自动降级,又该如何更细粒度的控制?

​ 基于上述的这些AI化的需求,我们可以为每一个服务分配一个降级权值,从而便于更加智能化的实现服务治理。而其评估的数值,同样也可以使用数学模型的方式进行 定性定量 的评估出来,也可以架构师根据经验直接拍脑袋来确定。

5 总结与展望

​ 以上提供了半实际与半理论的服务降级方案,使用者可以根据其公司的实际情况进行适当的选择,而完整的方案,笔者目前也没有发现有实施过的,但可以建议有长远服务治理规划的大厂进行完整方案的研究与实施,会对未来人工智能万物互联的时代有较好的治理价值存在(个人看法)。而小厂出于成本和其发挥的价值的考虑,不建议使用这么复杂的方案,但可以实现分布式开关和简单分级降级的功能特性。

​ 本文主要以服务降级为核心进行更加理想的治理微服务架构,其中建议运用数学领域的适当模型来实现 定性定量 的合理分析和治理微服务,为未来 人工智能治理微服务(Artificial Intelligence Governance Micro Service,简称AIGMS)提供方案支持。


本文整理自

服务降级

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


前言

公司最近在搞服务分离,数据切分方面的东西,因为单张包裹表的数据量实在是太大,并且还在以每天60W的量增长。 之前了解过数据库的分库分表,读过几篇博文,但就只知道个模糊概念, 而且现在回想起来什么都是模模糊糊的。

今天看了一下午的数据库分库分表,看了很多文章,现在做个总结,“摘抄”下来。(但更期待后期的实操) 会从以下几个方面说起:

第一部分:实际网站发展过程中面临的问题。

第二部分:有哪几种切分方式,垂直和水平的区别和适用面。

第三部分:目前市面有的一些开源产品,技术,它们的优缺点是什么。

第四部分:可能是最重要的,为什么不建议水平分库分表!?这能让你能在规划前期谨慎的对待,规避掉切分造成的问题。

名词解释

库:database;表:table;分库分表:sharding

数据库架构演变

刚开始我们只用单机数据库就够了,随后面对越来越多的请求,我们将数据库的写操作和读操作进行分离, 使用多个从库副本(Slaver Replication)负责读,使用主库(Master)负责写, 从库请求主库同步更新数据,保持数据一致。架构上就是数据库主从同步。 从库可以水平扩展,所以更多的读请求不成问题。

但是当用户量级上来后,写请求越来越多,该怎么办?加一个Master是不能解决问题的, 因为数据要保存一致性,写操作需要2个master之间同步,相当于是重复了,而且更加复杂。

这时就需要用到分库分表(sharding),对写操作进行切分

分库分表前的问题

任何问题都是太大或者太小的问题,我们这里面对的数据量太大的问题。

用户请求量太大

因为单服务器TPS,内存,IO都是有限的。 解决方法:分散请求到多个服务器上; 其实用户请求和执行一个sql查询是本质是一样的,都是请求一个资源,只是用户请求还会经过网关,路由,http服务器等。

单库太大

单个数据库处理能力有限;单库所在服务器上磁盘空间不足;单库上操作的IO瓶颈 解决方法:切分成更多更小的库

单表太大

CRUD都成问题;索引膨胀,查询超时 解决方法:切分成多个数据集更小的表。

分库分表的方式方法

一般就是垂直切分和水平切分,这是一种结果集描述的切分方式,是物理空间上的切分。 我们从面临的问题,开始解决,阐述: 首先是用户请求量太大,我们就堆机器搞定(这不是本文重点)。

然后是单个库太大,这时我们要看是因为表多而导致数据多,还是因为单张表里面的数据多。 如果是因为表多而数据多,使用垂直切分,根据业务切分成不同的库

如果是因为单张表的数据量太大,这时要用水平切分,即把表的数据按某种规则切分成多张表,甚至多个库上的多张表。 分库分表的顺序应该是先垂直分,后水平分。 因为垂直分更简单,更符合我们处理现实世界问题的方式。

垂直拆分

  1. 垂直分表

    也就是“大表拆小表”,基于列字段进行的。一般是表中的字段较多,将不常用的, 数据较大,长度较长(比如text类型字段)的拆分到“扩展表“。 一般是针对那种几百列的大表,也避免查询时,数据量太大造成的“跨页”问题。

  2. 垂直分库

    垂直分库针对的是一个系统中的不同业务进行拆分,比如用户User一个库,商品Producet一个库,订单Order一个库。 切分后,要放在多个服务器上,而不是一个服务器上。为什么? 我们想象一下,一个购物网站对外提供服务,会有用户,商品,订单等的CRUD。没拆分之前, 全部都是落到单一的库上的,这会让数据库的单库处理能力成为瓶颈。按垂直分库后,如果还是放在一个数据库服务器上, 随着用户量增大,这会让单个数据库的处理能力成为瓶颈,还有单个服务器的磁盘空间,内存,tps等非常吃紧。 所以我们要拆分到多个服务器上,这样上面的问题都解决了,以后也不会面对单机资源问题。

    数据库业务层面的拆分,和服务的“治理”,“降级”机制类似,也能对不同业务的数据分别的进行管理,维护,监控,扩展等。 数据库往往最容易成为应用系统的瓶颈,而数据库本身属于“有状态”的,相对于Web和应用服务器来讲,是比较难实现“横向扩展”的。 数据库的连接资源比较宝贵且单机处理能力也有限,在高并发场景下,垂直分库一定程度上能够突破IO、连接数及单机硬件资源的瓶颈。

水平拆分

  1. 水平分表

    针对数据量巨大的单张表(比如订单表),按照某种规则(RANGE,HASH取模等),切分到多张表里面去。 但是这些表还是在同一个库中,所以库级别的数据库操作还是有IO瓶颈。不建议采用。

  2. 水平分库分表

    将单张表的数据切分到多个服务器上去,每个服务器具有相应的库与表,只是表中数据集合不同。 水平分库分表能够有效的缓解单机和单库的性能瓶颈和压力,突破IO、连接数、硬件资源等的瓶颈。

  3. 水平分库分表切分规则

    1. RANGE

      从0到10000一个表,10001到20000一个表;

    2. HASH取模

      一个商场系统,一般都是将用户,订单作为主表,然后将和它们相关的作为附表,这样不会造成跨库事务之类的问题。 取用户id,然后hash取模,分配到不同的数据库上。

    3. 地理区域

      比如按照华东,华南,华北这样来区分业务,七牛云应该就是如此。

    4. 时间

      按照时间切分,就是将6个月前,甚至一年前的数据切出去放到另外的一张表,因为随着时间流逝,这些表的数据 被查询的概率变小,所以没必要和“热数据”放在一起,这个也是“冷热数据分离”。

分库分表后面临的问题

事务支持

分库分表后,就成了分布式事务了。如果依赖数据库本身的分布式事务管理功能去执行事务,将付出高昂的性能代价; 如果由应用程序去协助控制,形成程序逻辑上的事务,又会造成编程方面的负担。

多库结果集合并(group by,order by)

TODO

跨库join

TODO 分库分表后表之间的关联操作将受到限制,我们无法join位于不同分库的表,也无法join分表粒度不同的表, 结果原本一次查询能够完成的业务,可能需要多次查询才能完成。 粗略的解决方法: 全局表:基础数据,所有库都拷贝一份。 字段冗余:这样有些字段就不用join去查询了。 系统层组装:分别查询出所有,然后组装起来,较复杂。

分库分表方案产品

目前市面上的分库分表中间件相对较多,其中基于代理方式的有MySQL Proxy和Amoeba, 基于Hibernate框架的是Hibernate Shards,基于jdbc的有当当sharding-jdbc, 基于mybatis的类似maven插件式的有蘑菇街的蘑菇街TSharding, 通过重写spring的ibatis template类的Cobar Client。

还有一些大公司的开源产品:

img


本文整理自

MySQL 分库分表方案

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


TCP 协议是我们几乎每天都会接触到的网络协议,绝大多数网络连接的建立都是基于 TCP 协议的,学过计算机网络或者对 TCP 协议稍有了解的人都知道 —— 使用 TCP 协议建立连接需要经过三次握手(three-way handshake)。

如果让我们简单说说 TCP 建立连接的过程,相信很多准备过面试的人都会非常了解,但是一旦想要深究『为什么 TCP 建立连接需要三次握手?』,作者相信大多数人都没有办法回答这个问题或者会给出错误的答案,这边文章就会讨论究竟为什么我们需要三次握手才能建立 TCP 连接?

需要注意的是我们会将重点放到为什么需要 TCP 建立连接需要『三次握手』,而不仅仅是为什么需要『三次』握手。

概述

在具体分析今天的问题之前,我们首先可以了解一下最常见的错误类比,这个对 TCP 连接过程的错误比喻误导了很多人,作者在比较长的一段时间内也认为它能够很好地描述 TCP 建立连接为什么需要三次握手:

  1. 你听得到吗?
  2. 我能听到,你听得到?
  3. 我也能听到;

这种用类比来解释问题往往就会面临『十个类比九个错』的尴尬局面,如果别人用类比回答你的为什么,你需要仔细想一想它的类比里究竟哪里有漏洞;类比带来的解释往往只能有片面的相似性,我们永远也无法找到绝对正确的类比,它只在我们想要通俗易懂地展示事物的特性时才能发挥较大的作用,我们在文章的后面会介绍为什么这里的类比有问题,各位读者也可以带着疑问来阅读剩下的内容。

很多人尝试回答或者思考这个问题的时候其实关注点都放在了三次握手中的三次上面,这确实很重要,但是如果重新审视这个问题,我们对于『什么是连接』真的清楚?只有知道连接的定义,我们才能去尝试回答为什么 TCP 建立连接需要三次握手。

The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.

RFC 793 - Transmission Control Protocol 文档中非常清楚地定义了 TCP 中的连接是什么,我们简单总结一下:用于保证可靠性和流控制机制的信息,包括 Socket、序列号以及窗口大小叫做连接。

what-is-tcp-connection

所以,建立 TCP 连接就是通信的双方需要对上述的三种信息达成共识,连接中的一对 Socket 是由互联网地址标志符和端口组成的,窗口大小主要用来做流控制,最后的序列号是用来追踪通信发起方发送的数据包序号,接收方可以通过序列号向发送方确认某个数据包的成功接收。

到这里,我们将原有的问题转换成了『为什么需要通过三次握手才可以初始化 Sockets、窗口大小和初始序列号?』,那么接下来我们就开始对这个细化的问题进行分析并寻找解释。

设计

这篇文章主要会从以下几个方面介绍为什么我们需要通过三次握手才可以初始化 Sockets、窗口大小、初始序列号并建立 TCP 连接:

  • 通过三次握手才能阻止重复历史连接的初始化;
  • 通过三次握手才能对通信双方的初始序列号进行初始化;
  • 讨论其他次数握手建立连接的可能性;

这几个论点中的第一个是 TCP 选择使用三次握手的最主要原因,其他的几个原因相比之下都是次要的原因,我们在这里对它们的讨论只是为了让整个视角更加丰富,通过多方面理解这一有趣的设计决策。

历史连接

RFC 793 - Transmission Control Protocol 其实就指出了 TCP 连接使用三次握手的首要原因 —— 为了阻止历史的重复连接初始化造成的混乱问题,防止使用 TCP 协议通信的双方建立了错误的连接。

The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

tcp-recovery-from-old-duplicate-syn

想象一下这个场景,如果通信双方的通信次数只有两次,那么发送方一旦发出建立连接的请求之后它就没有办法撤回这一次请求,如果在网络状况复杂或者较差的网络中,发送方连续发送多次建立连接的请求,如果 TCP 建立连接只能通信两次,那么接收方只能选择接受或者拒绝发送方发起的请求,它并不清楚这一次请求是不是由于网络拥堵而早早过期的连接。

所以,TCP 选择使用三次握手来建立连接并在连接引入了 RST 这一控制消息,接收方当收到请求时会将发送方发来的 SEQ+1 发送给对方,这时由发送方来判断当前连接是否是历史连接

  • 如果当前连接是历史连接,即 SEQ 过期或者超时,那么发送方就会直接发送 RST 控制消息中止这一次连接;
  • 如果当前连接不是历史连接,那么发送方就会发送 ACK 控制消息,通信双方就会成功建立连接;

使用三次握手和 RST 控制消息将是否建立连接的最终控制权交给了发送方,因为只有发送方有足够的上下文来判断当前连接是否是错误的或者过期的,这也是 TCP 使用三次握手建立连接的最主要原因。

初始序列号

另一个使用三次握手的重要的原因就是通信双方都需要获得一个用于发送信息的初始化序列号,作为一个可靠的传输层协议,TCP 需要在不稳定的网络环境中构建一个可靠的传输层,网络的不确定性可能会导致数据包的缺失和顺序颠倒等问题,常见的问题可能包括:

  • 数据包被发送方多次发送造成数据的重复;
  • 数据包在传输的过程中被路由或者其他节点丢失;
  • 数据包到达接收方可能无法按照发送顺序;

为了解决上述这些可能存在的问题,TCP 协议要求发送方在数据包中加入『序列号』字段,有了数据包对应的序列号,我们就可以:

  • 接收方可以通过序列号对重复的数据包进行去重;
  • 发送方会在对应数据包未被 ACK 时进行重复发送;
  • 接收方可以根据数据包的序列号对它们进行重新排序;

序列号在 TCP 连接中有着非常重要的作用,初始序列号作为 TCP 连接的一部分也需要在三次握手期间进行初始化,由于 TCP 连接通信的双方都需要获得初始序列号,所以它们其实需要向对方发送 SYN 控制消息并携带自己期望的初始化序列号 SEQ,对方在收到 SYN 消息之后会通过 ACK 控制消息以及 SEQ+1 来进行确认。

basic-4-way-handshake

如上图所示,通信双方的两个 TCP A/B 分别向对方发送 SYNACK 控制消息,等待通信双方都获取到了自己期望的初始化序列号之后就可以开始通信了,由于 TCP 消息头的设计,我们可以将中间的两次通信合成一个,TCP B 可以向 TCP A 同时发送 ACKSYN 控制消息,这也就帮助我们将四次通信减少至三次。

A three way handshake is necessary because sequence numbers are not tied to a global clock in the network, and TCPs may have different mechanisms for picking the ISN’s. The receiver of the first SYN has no way of knowing whether the segment was an old delayed one or not, unless it remembers the last sequence number used on the connection (which is not always possible), and so it must ask the sender to verify this SYN. The three way handshake and the advantages of a clock-driven scheme are discussed in [3].

除此之外,网络作为一个分布式的系统,其中并不存在一个用于计数的全局时钟,而 TCP 可以通过不同的机制来初始化序列号,作为 TCP 连接的接收方我们无法判断对方传来的初始化序列号是否过期,所以我们需要交由对方来判断,TCP 连接的发起方可以通过保存发出的序列号判断连接是否过期,如果让接收方来保存并判断序列号却是不现实的,这也再一次强化了我们在上一节中提出的观点 —— 避免历史错连接的初始化。

通信次数

当我们讨论 TCP 建立连接需要的通信次数时,我们经常会执着于为什么通信三次才可以建立连接,而不是两次或者四次;讨论使用更多的通信次数来建立连接往往是没有意义的,因为我们总可以使用更多的通信次数交换相同的信息,所以使用四次、五次或者更多次数建立连接在技术上都是完全可以实现的。

basic-3-way-handshake

这种增加 TCP 连接通信次数的问题往往没有讨论的必要性,我们追求的其实是用更少的通信次数(理论上的边界)完成信息的交换,也就是为什么我们在上两节中也一再强调使用『两次握手』没有办法建立 TCP 连接,使用三次握手是建立连接所需要的最小次数

总结

我们在这篇文章中讨论了为什么 TCP 建立连接需要经过三次握手,在具体分析这个问题之前,我们首先重新思考了 TCP 连接究竟是什么,RFC 793 - Transmission Control Protocol - IETF Tools 对 TCP 连接有着非常清楚的定义 —— 用于保证可靠性和流控制机制的数据,包括 Socket、序列号以及窗口大小。

TCP 建立连接时通过三次握手可以有效地避免历史错误连接的建立,减少通信双方不必要的资源消耗,三次握手能够帮助通信双方获取初始化序列号,它们能够保证数据包传输的不重不丢,还能保证它们的传输顺序,不会因为网络传输的问题发生混乱,到这里不使用『两次握手』和『四次握手』的原因已经非常清楚了:

  • 『两次握手』:无法避免历史错误连接的初始化,浪费接收方的资源;
  • 『四次握手』:TCP 协议的设计可以让我们同时传递 ACKSYN 两个控制信息,减少了通信次数,所以不需要使用更多的通信次数传输相同的信息;

我们重新回到在文章开头提的问题,为什么使用类比解释 TCP 使用三次握手是错误的?这主要还是因为,这个类比没有解释清楚核心问题 —— 避免历史上的重复连接。到最后,我们还是来看一些比较开放的相关问题,有兴趣的读者可以仔细想一下下面的问题:

  • 除了使用序列号是否还有其他方式保证消息的不重不丢?
  • UDP 协议有连接的概念么,它能保证数据传输的可靠么?

本文整理自

为什么 TCP 建立连接需要三次握手

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


目录

在Shell脚本中处理命令行参数,可以使用getopts/getopt来进行——当然,手工解析也是可以的。

下面通过一个特定的情景来讲一下这三种参数处理方法。

这两天写了一个安全删除的脚本,原理就是将指定的文件移动到某个特定的目录下并保存其原始路径信息,这和在Windows下以及在Linux的桌面环境下”将文件移动到回收站”的意义是一样的。就拿这个来做例子吧。

在这个脚本中,有五个选项,分别代表五种动作:

  1. -d : 将文件移动到回收站,该选项后需要指定一个文件或目录名
  2. -l : 列出被移动到回收站的文件及其id,该选项不需要值
  3. -b : 恢复被移动到回收站的文件,该选项需要指定一个文件对应的id
  4. -c : 清空回收站,该选项不需要值
  5. -h : 打印帮助信息

手工解析

所谓的手工解析,就是取到参数后手工一个一个解析了,以下是手工解析上述情景参数的过程:

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
while [ $# -gt 0 ];do
case $1 in
-d)
shift
file_to_trash=$1
trash $file_to_trash # trash is a function
;;
-l)
print_trashed_file # print_trashed_file is a function
;;
-b)
shift
file_to_untrash=$1
untrash $file_to_untrash # untrash is a function
;;
-c)
clean_all # clean all is a function
;;
-h)
usage
exit 0
;;
\?)
usage
exit 1
;;
esac
done

这里用到了’shift’这个命令,这个命令的作用是将参数列表以空格为分隔符左移一个单位,或者可以理解为将第一个参数给去掉了,比如获取的命令行参数为:

1
-d hello.txt

在执行了’shift’后,命令行参数就变成了

1
hello.txt

这样,在使用了shift后,我们每次都只要去看参数列表中的第一个就行了。当然,其实不用’shift’也是可以的,比如说这样:

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
i=1
while [ $i -le $# ];do
case ${!i} in
-d)
i=$(expr $i + 1)
file_to_trash=${!i}
trash $file_to_trash # trash is a function
;;
-l)
print_trashed_file # print_trashed_file is a function
;;
-b)
i=$(expr $i + 1)
file_to_untrash=${!i}
untrash $file_to_untrash # untrash is a function
;;
-c)
clean_all # clean all is a function
;;
-h)
usage
exit 0
;;
\?)
usage
exit 1
;;
esac
i=$(expr $i + 1)
done

对比可以发现使用’shift’会稍微方便一点。

当然,上面的处理没有进行参数检查,这些检查应该要防止这些错误情况:参数个数为0、完全冲突的”动作”一起出现、选项需要值但未给值。

getopts

‘getopts’是POSIX Shell中内置的一个命令,其使用方法是:

1
getopts <opt_string> <optvar> <arguments>

下面是在Shell中使用该命令的一个示例:

getopts.gif

本质上来说,’getopts’的处理和我们手工处理是差不多的,它不过是提供了更便利的方式而已。它的使用方式非常简单明了,其形式为:

1
2
3
4
5
while getopts <opt_string> <optvar>
case $<optvar> in
# ...
esac
done

其中是要处理的选项的一个集合,每个选项在其中用不包含连字符’-‘的字母来表示,每个代表选项的字母前后可以有一个冒号,前面有冒号表示当处理该选项出错时不输出’getopts’自身产生的错误信息,这方便我们自己编写对应的错误处理方法;后面的冒号表示这个选项需要一个值。对于我们这个”安全删除”的例子,这个应该是:

1
d:lb:ch

冒号的归属的话,先到先得吧,大概是这样。

在使用’getopts’时,有两个特殊的变量,它们是 OPTINDOPTARG ,前者表示当前参数在参数列表中的位置——相当于手工解析第二种方法中那个自定义的变量 i ,其值初始时为1, 会在每次取了选项以及其值(如果有的话)后更新; OPTARG 则是在选项需要值时,存储这个选项对应的值。这样,我们这个例子用’getopts’就可以写成:

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
while getopts d:lb:ch OPT;do
case $OPT in
d)
file_to_trash=$OPTARG
trash $file_to_trash # trash is a function
;;
l)
print_trashed_file # print_trashed_file is a function
;;
b)
file_to_untrash=$OPTARG
untrash $file_to_untrash # untrash is a function
;;
c)
clean_all # clean all is a function
;;
h)
usage
exit 0
;;
\?)
usage
exit 1
;;
esac
done

对比可以看到,相比手工解析的第一种办法,又更为简洁一点了。不过需要注意的是,’getopts’会从第一个参数开始,只按照指定的形式来寻找并解析参数,如果给出的实际命令行参数与其所描述的参数形式不符,则会出错中止。

比如说,对于上面的例子,假设这个脚本已经完全写好了,脚本名为 trash.sh ,其参数处理就是上面这样,那么如果我在终端里执行:

1
./trash.sh a -b hello.txt

开始那个多余的参数’a’将会导致’getopts’在解析到选项’-b’前就出错终止。所以呢,像使用’getopts’这样的方法,其自由度不如手工解析,如果要保证脚本在任何情况下都能正确解析参数,它需要多做一点——当然啦,上面这个愚蠢的错误使用情况还是比较少出现的啦,反正我现在写的脚本里压根没考虑这样的情况。

getopt

‘getopt’与’getopts’类似,不过’getopts’只能处理短选项,’getopt’则能处理短选项和长选项。所谓的短选项就是类似下面这样的选项:

1
-a

而下面这样的则是长选项

1
--action=delete

当然,事无绝对,通过一些技巧,用’getopts’处理长选项也是可能的。这里先说一下如何用’getopt’来处理参数吧。

需要事先说明的一点是,’getopt’不是Shell内建的命令,而是’util-linux’这个软件包提供的功能,它不是POSIX标准的一部分,所以也有人建议不使用’getopt’

首先将之前说到的五种动作对应的短选项扩展一下,以便讲解’getopt’的使用:

  1. -d/–delete : 将文件移动到回收站,该选项后需要指定一个文件或目录名
  2. -l/–list : 列出被移动到回收站的文件及其id,该选项不需要值
  3. -b/–back : 恢复被移动到回收站的文件,该选项需要指定一个文件对应的id
  4. -c/–clear : 清空回收站,该选项不需要值
  5. -h/–help : 打印帮助信息

‘getopt’既能处理短选项也能处理长选项,短选项通过参数 -o 指定,长选项通过参数 -l 指定。同’getopts’一样,它一次也只解析一个选项,所以也需要循环处理,不过与’getopts’不同的是,’getopt’没有使用 OPTINDOPTARG 这两个变量,所以我们还得手动对参数进行’shift’,对需要值的选项,也得手动去取出值。

下面是在Shell中使用’getopt’的一个示例:

getopt.gif

可以看到,’getopt’将参数中以下形式的内容:

1
--longopt=argument

在返回结果中替换成下面这样的形式:

1
--longopt argument

这样就可以通过循环和’shift’来进行处理了,不过在脚本中,’shift’命令是对命令行参数起作用的,即特殊变量”$@”,而我们在脚本中只能将’getopt’的返回结果作为字符串存储到一个变量中。为了让’shift’起作用,通常还要使用’set’命令来将变量的值赋给”$@”这个特殊变量。

真是有够麻烦的……算了,下面再集中吐槽吧……

然后,在设置好短选项和长选项后,在将实际的参数传给’getopt’时,要在实际参数前加上一个两个连字符 ,而’getopt’会将这两个连字符放到返回结果的最后面,在处理时可以将这两个连字符视为结束标志。

以下是针对本文假设的情景,使用’getopt’解析参数的流程:

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
arg=$(getopt -o d:lb:ch -l delete:,list,back:,clear,help -- $@)

set -- "$arg"

while true
do
case $1 in
-d|--delete)
file_to_trash=$2
trash $file_to_trash # trash is a function
shift 2
;;
-l|--list)
print_trashed_file # print_trashed_file is a function
shift
;;
-b|--back)
file_to_untrash=$2
untrash $file_to_untrash # untrash is a function
shift
;;
-c|--clear)
clean_all # clean all is a function
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
esac
done

然而,知道了’getopt’的使用及其原理后,自然而然地可以发现,我可以不用去管这个结束标志,用”$#”这个表示参数个数的特殊变量,同样可以控制参数解析的流程,这完全和手工解析是同一个道理。我甚至可以将’getopt’的返回结果存储到一个数组里,直接循环处理这个数组,而不用使用’set’命令了。

好了,吐槽时间。

我之前写脚本都是用的’getopts’,一来我用不上长选项,二来’getopts’的使用足够简单。在写本文之前,我倒是知道’getopt’可以处理长选项,但没仔细了解过。这两天了解了一下,觉得还是别用’getopt’的好,理由如下:

  1. ‘getopt’不是Shell内建命令,跨平台使用时可能会出现问题;

  2. 只是将’–longopt=val’这样的参数形式替换成了’–longopt val’,但因此增加了许多复杂性,比如使用了’set’命令,在使用’set’命令时还要考虑’getopt’的返回结果中有无Shell命令,有的话应该使用’eval’命令来消除可能导致的错误

    1
    eval set -- "$arg"
  3. 调用完还要进行与手工解析类似的工作,相比手工解析,并没有多大优势;

  4. 真的需要长选项吗?我觉得短选项就足够了

getopts处理长选项

既然不建议使用’getopt’,那么怎么处理长选项呢?自然是有办法的。

为了方便讲解,这里假设一个简单的情景吧,在这个情景里,我们只需要处理两个可能的选项

  1. -f/–file: 设置文件名,该选项需要值
  2. -h/–help: 打印帮助信息,该选项不需要值

用’getopts’处理这种情况,可以这么做:

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
filename=""
while getopts f:h-: opt;do
case $opt in
-)
case $OPTARG in
help)
usage
exit 0
;;
file=*)
filename=${OPTARG#*=}
;;
esac
;;
f)
filename=$OPTARG
;;
h)
usage
exit 0
;;
\?)
usage
exit 1
;;
esac
done

当然,也许并不比手工解析简洁多少,但用起来肯定是比’getopt’要舒服的。

在函数中解析参数

有时候,我们也许想把参数解析的工作放到函数中去做,比如说定义了一个’main’函数然后在’main’函数中封装整个流程处理逻辑。又或者像我一样,写了几个小小的工具函数,放到了Bash的配置文件 .bashrc 中,参数解析的工作必须得在函数中做。

手工解析是能想到的最直接的办法,简单可行。

不过假如我们想用’getopts’来处理呢?动手尝试后,你会发现直接在函数中使用’getopts’是会出错的。要在函数中使用’getopts’,必须在这个函数中使用’getopts’前,将 OPTIND 这个被’getopts’使用的特殊变量设置为函数局部变量,像这样:

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
function main() {

local OPTIND

while getopts d:lb:ch OPT;do
case $OPT in
d)
file_to_trash=$OPTARG
trash $file_to_trash # trash is a function
;;
l)
print_trashed_file # print_trashed_file is a function
;;
b)
file_to_untrash=$OPTARG
untrash $file_to_untrash # untrash is a function
;;
c)
clean_all # clean all is a function
;;
h)
usage
exit 0
;;
\?)
usage
exit 1
;;
esac
done
}

main $@

本文整理自

Shell脚本中参数处理方法

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


就我个人而言,我觉得Redis的基本使用是我们每个Java程序员都应该会的。《Redis实战》、《Redis设计与实现》是我比较推荐的两本学习Redis的书籍。

  1. Redis的两种持久化操作以及如何保障数据安全(快照和AOF)
  2. 如何防止数据出错(Redis事务)
  3. 如何使用流水线来提升性能
  4. Redis主从复制
  5. Redis集群的搭建
  6. Redis的几种淘汰策略
  7. Redis集群宕机,数据迁移问题
  8. Redis缓存使用有很多,怎么解决缓存雪崩和缓存穿透?

下面就一些问题给大家详细说一下。

什么是Redis?

Redis 是一个使用 C 语言写成的,开源的 key-value 数据库。。和Memcached类似,它支持存储的value类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set –有序集合)和hash(哈希类型)。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis支持各种不同方式的排序。与memcached一样,为了保证效率,数据都是缓存在内存中。区别的是redis会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了master-slave(主从)同步。目前,Vmware在资助着redis项目的开发和维护。

Redis与Memcached的区别与比较

1 、Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,zset,hash等数据结构的存储。memcache支持简单的数据类型,String。

2 、Redis支持数据的备份,即master-slave模式的数据备份。

3 、Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而Memecache把数据全部存在内存之中

4、 redis的速度比memcached快很多

5、Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的IO复用模型。

Redis与Memcached的区别与比较

如果想要更详细了解的话,可以查看慕课网上的这篇手记(非常推荐) :《脚踏两只船的困惑 - Memcached与Redis》www.imooc.com/article/235…

Redis与Memcached的选择

终极策略: 使用Redis的String类型做的事,都可以用Memcached替换,以此换取更好的性能提升; 除此以外,优先考虑Redis;

使用redis有哪些好处?

(1) 速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1)

(2)支持丰富数据类型,支持string,list,set,sorted set,hash

(3) 支持事务 :redis对事务是部分支持的,如果是在入队时报错,那么都不会执行;在非入队时报错,那么成功的就会成功执行。详细了解请参考:《Redis事务介绍(四)》:blog.csdn.net/cuipeng0916…

redis监控:锁的介绍

(4) 丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除

Redis常见数据结构使用场景

1. String

常用命令: set,get,decr,incr,mget 等。

String数据结构是简单的key-value类型,value其实不仅可以是String,也可以是数字。 常规key-value缓存应用; 常规计数:微博数,粉丝数等。

2.Hash

常用命令: hget,hset,hgetall 等。

Hash是一个String类型的field和value的映射表,hash特别适合用于存储对象。 比如我们可以Hash数据结构来存储用户信息,商品信息等等。

举个例子: 最近做的一个电商网站项目的首页就使用了redis的hash数据结构进行缓存,因为一个网站的首页访问量是最大的,所以通常网站的首页可以通过redis缓存来提高性能和并发量。我用jedis客户端来连接和操作我搭建的redis集群或者单机redis,利用jedis可以很容易的对redis进行相关操作,总的来说从搭一个简单的集群到实现redis作为缓存的整个步骤不难。感兴趣的可以看我昨天写的这篇文章:

《一文轻松搞懂redis集群原理及搭建与使用》: juejin.im/post/5ad54d…

3.List

常用命令: lpush,rpush,lpop,rpop,lrange等

list就是链表,Redis list的应用场景非常多,也是Redis最重要的数据结构之一,比如微博的关注列表,粉丝列表,最新消息排行等功能都可以用Redis的list结构来实现。

Redis list的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

4.Set

常用命令: sadd,spop,smembers,sunion 等

set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的。 当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。

在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis可以非常方便的实现如共同关注、共同喜好、二度好友等功能。

5.Sorted Set

常用命令: zadd,zrange,zrem,zcard等

和set相比,sorted set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。

举例: 在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息,适合使用Redis中的SortedSet结构进行存储。

MySQL里有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据(redis有哪些数据淘汰策略???)

   相关知识:redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略(回收策略)。redis 提供 6种数据淘汰策略:

  1. volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-enviction:禁止驱逐(淘汰)数据

Redis的并发竞争问题如何解决?

Redis为单进程单线程模式,采用队列模式将并发访问变为串行访问。Redis本身没有锁的概念,Redis对于多个客户端连接并不存在竞争,但是在Jedis客户端对Redis进行并发访问时会发生连接超时、数据转换错误、阻塞、客户端关闭连接等问题,这些问题均是由于客户端连接混乱造成。对此有2种解决方法:

 1. 客户端角度,为保证每个客户端间正常有序与Redis进行通信,对连接进行池化,同时对客户端读写Redis操作采用内部锁synchronized。

  1. 服务器角度,利用setnx实现锁。

 注:对于第一种,需要应用程序自己处理资源的同步,可以使用的方法比较通俗,可以使用synchronized也可以使用lock;第二种需要用到Redis的setnx命令,但是需要注意一些问题。

Redis回收进程如何工作的? Redis回收使用的是什么算法?

Redis内存回收:LRU算法(写的很不错,推荐)www.cnblogs.com/WJ5888/p/43…

Redis 大量数据插入

官方文档给的解释:www.redis.cn/topics/mass…

Redis 分区的优势、不足以及分区类型

官方文档提供的讲解:www.redis.net.cn/tutorial/35…

Redis持久化数据和缓存怎么做扩容?

《redis的持久化和缓存机制》github.com/Snailclimb/…

扩容的话可以通过redis集群实现,之前做项目的时候用过自己搭的redis集群 然后写了一篇关于redis集群的文章:《一文轻松搞懂redis集群原理及搭建与使用》juejin.im/post/5ad54d…

Redis常见性能问题和解决方案:

  1. Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件
  2. 如果数据比较重要,某个Slave开启AOF备份数据,策略设置为每秒同步一次
  3. 为了主从复制的速度和连接的稳定性,Master和Slave最好在同一个局域网内
  4. 尽量避免在压力很大的主库上增加从库

Redis与消息队列

作者:翁伟 链接:https://www.zhihu.com/question/20795043/answer/345073457

不要使用redis去做消息队列,这不是redis的设计目标。但实在太多人使用redis去做去消息队列,redis的作者看不下去,另外基于redis的核心代码,另外实现了一个消息队列disque: antirez/disque:github.com/antirez/dis…部署、协议等方面都跟redis非常类似,并且支持集群,延迟消息等等。

我在做网站过程接触比较多的还是使用redis做缓存,比如秒杀系统,首页缓存等等。

好文Mark

非常非常推荐下面几篇文章。。。

《Redis深入之道:原理解析、场景使用以及视频解读》zhuanlan.zhihu.com/p/28073983: 主要介绍了:Redis集群开源的方案、Redis协议简介及持久化Aof文件解析、Redis短连接性能优化等等内容,文章干货太大,容量很大,建议时间充裕可以看看。另外文章里面还提供了视频讲解,可以说是非常非常用心了。

《阿里云Redis混合存储典型场景:如何轻松搭建视频直播间系统》:yq.aliyun.com/articles/58…: 主要介绍视频直播间系统,以及如何使用阿里云Redis混合存储实例方便快捷的构建大数据量,低延迟的视频直播间服务。还介绍到了我们之前提高过的redis的数据结构的使用场景

《美团在Redis上踩过的一些坑-5.redis cluster遇到的一些问》carlosfu.iteye.com/blog/225457…:主要介绍了redis集群的两个常见问题,然后分享了 一些关于redis集群不错的文章。


本文整理自

面试中关于Redis的问题看这篇就够了

https://www.cnblogs.com/Survivalist/p/8119891.html

http://www.redis.net.cn/tutorial/3524.html

https://redis.io/

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!